-
Notifications
You must be signed in to change notification settings - Fork 92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feature/sc-123395/implement-create-command #691
Changes from 14 commits
474e69d
0e904d0
e35d548
0ba07aa
f8e2bf8
f6d8e2a
f1c546c
f224b2f
4655562
7eda5a5
afcc76e
6725d64
c79c251
786952a
348a583
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
declare module 'particle:core' { | ||
namespace Particle { | ||
/***********************************************LEDGER**************************************************************/ | ||
export const MERGE: "Merge"; | ||
export const REPLACE: "Replace"; | ||
export interface Ledger { | ||
/** | ||
* Gets the current value of the ledger. | ||
* Empty ledger returns {}, error returns null | ||
* @returns {Object} the current value of the ledger | ||
* @throws {Error} if the ledger doesn't exist or there was an error getting the ledger instance | ||
*/ | ||
get(): { updatedAt: string, data: Record<string, any> }, | ||
|
||
/** | ||
* Sets the value of the ledger. If the ledger does not exist, it will be created. | ||
* | ||
* @param data The data you would like to put into the ledger | ||
* @param setMode Whether to replace or merge the new data into the ledger. Defaults to Replace. You can use the constants MERGE and REPLACE here | ||
* @throws {Error} if the ledger doesn't exist or there was an error setting the ledger instance | ||
*/ | ||
set(data: Record<string, any>, setMode: "Replace" | "Merge"): null, | ||
|
||
/** | ||
* Deletes the ledger data | ||
* @throws {Error} if there was an error deleting the ledger instance | ||
*/ | ||
delete(): null | ||
} | ||
/** | ||
* Gets a ledger object which allows you to store data in the cloud. The scope value will automatically be picked | ||
* based on the trigger's context. If you want to use a different scope value, you can provide a deviceId or productId | ||
* depending on your needs in the second param. | ||
* | ||
* If the trigger contains a deviceId and productId, we will pass those to ledger and use what the ledger definition requires | ||
* | ||
* @param name a unique name regardless of the definition's scope | ||
* @param scopeValues the override scope values if you want to ignore the logic trigger or are using a scheduled logic trigger | ||
*/ | ||
export function ledger(name: string, scopeValues?: { deviceId?: string, productId?: number }): Ledger; | ||
|
||
/***********************************************CONTEXT*************************************************************/ | ||
export interface FunctionInfo { | ||
ownerId: string; | ||
logicFunctionId: string; | ||
} | ||
|
||
export interface TriggerInfo { | ||
triggerEventId?: string; | ||
triggeredAt: string; | ||
} | ||
|
||
export interface EventInfo { | ||
publishedAt: string; | ||
eventName: string; | ||
eventData?: string; | ||
deviceId: string; | ||
productId?: number; | ||
userId?: string; | ||
} | ||
|
||
export interface ScheduledInfo { | ||
scheduledAt: string; | ||
startAt: string; | ||
endAt?: string; | ||
} | ||
|
||
export interface LedgerChangeInfo { | ||
changedAt: string; | ||
changeType: 'set' | 'delete'; | ||
name: string; | ||
scope: string; | ||
data: Record<string, any> | ||
} | ||
|
||
/** | ||
* The context object that is passed to the function when it is invoked. | ||
* The event, scheduled and ledgerChange properties will only be present if the function was invoked by that type of trigger | ||
* and only one of them will be present at a time (logic functions can only have one type of trigger). | ||
*/ | ||
export interface FunctionContext { | ||
functionInfo: FunctionInfo; | ||
trigger: TriggerInfo; | ||
event?: EventInfo; | ||
scheduled?: ScheduledInfo; | ||
ledgerChange?: LedgerChangeInfo; | ||
} | ||
|
||
/** | ||
* Returns an object containing the raw context, trigger and its details. | ||
*/ | ||
export function getContext(): FunctionContext; | ||
|
||
/***********************************************PUBLISH*************************************************************/ | ||
/** | ||
* Synchronously emits a Particle event message. Note that non-string values for `data` will be stringified | ||
* | ||
* @param name The name of the event | ||
* @param data The particle publish data | ||
* @param options Allows specifying how the data is published | ||
* @param options.productId Publish to the event stream of this product | ||
* @param options.asDeviceId Publish the event as if it came from this device. | ||
* @throws {Error} if there was an error publishing the event | ||
*/ | ||
export function publish(name: string, data: any | undefined, options: { productId: number, asDeviceId: string }): null; | ||
} | ||
|
||
export = Particle; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
declare module 'particle:encoding' { | ||
namespace Encoding { | ||
/** | ||
* Converts a byte array to a UTF-8 string | ||
* @param input The byte array to convert | ||
* @returns {string} The converted string | ||
*/ | ||
export function bytesToString(input: number[]): string; | ||
/** | ||
* Converts a UTF-8 string to a byte array | ||
* @param input The string to convert | ||
* @returns {number[]} The byte array representing this string | ||
*/ | ||
export function stringToBytes(input: string): number[]; | ||
/** | ||
* Encodes a string or byte array to base64 (RFC 3548) | ||
* @param input The string or byte array to encode | ||
* @returns {string} The base64 encoded string | ||
*/ | ||
export function base64Encode(input: string | number[]): string; | ||
/** | ||
* Decodes a base64 (RFC 3548) string to a byte array | ||
* @param input The base64 string to decode | ||
* @returns {number[]} The decoded byte array | ||
*/ | ||
export function base64Decode(input: string): number[]; | ||
/** | ||
* Encodes a string or byte array to base85 (RFC1924) | ||
* @param input The string or byte array to encode | ||
* @returns {string} The base85 encoded string | ||
*/ | ||
export function base85Encode(input: string | number[]): string; | ||
/** | ||
* Decodes a base85 (RFC1924) string to a byte array | ||
* @param input The base85 string to decode | ||
* @returns {number[]} The decoded byte array | ||
*/ | ||
export function base85Decode(input: string): number[]; | ||
} | ||
|
||
export = Encoding; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import Particle from 'particle:core'; | ||
|
||
export default function process({ functionInfo, trigger, event }) { | ||
// Add your code here | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
{ | ||
"$schema": "schemas/logic_function.schema.json", | ||
"logic_function": { | ||
"name": "${name}", | ||
"description": "${description}", | ||
"source": { | ||
"type": "JavaScript" | ||
}, | ||
"enabled": true, | ||
"logic_triggers": [] | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,14 @@ | ||
const os = require('os'); | ||
const path = require('path'); | ||
const ParticleAPI = require('./api'); | ||
const CLICommandBase = require('./base'); | ||
const VError = require('verror'); | ||
const settings = require('../../settings'); | ||
const { normalizedApiError } = require('../lib/api-client'); | ||
const CLICommandBase = require('./base'); | ||
const os = require('os'); | ||
const templateProcessor = require('../lib/template-processor'); | ||
const fs = require('fs-extra'); | ||
|
||
const logicFunctionTemplatePath = path.join(__dirname, '/../../assets/logicFunction'); | ||
|
||
/** | ||
* Commands for managing encryption keys. | ||
|
@@ -19,7 +24,7 @@ module.exports = class LogicFunctionsCommand extends CLICommandBase { | |
try { | ||
const logicFunctions = await api.getLogicFunctionList({ org }); | ||
const orgName = getOrgName(org); | ||
const list = logicFunctions['logic_functions']; | ||
const list = logicFunctions.logic_functions; | ||
if (list.length === 0) { | ||
this.ui.stdout.write(`No Logic Functions currently deployed in your ${orgName}.`); | ||
this.ui.stdout.write(`To create a Logic Function, see \`particle logic-function create\`.${os.EOL} | ||
|
@@ -35,6 +40,7 @@ module.exports = class LogicFunctionsCommand extends CLICommandBase { | |
}); | ||
this.ui.stdout.write(`${os.EOL}To view a Logic Function's code, see \`particle lf get.\`${os.EOL}`); | ||
} | ||
return list; | ||
} catch (e) { | ||
throw createAPIErrorResult({ error: e, message: 'Error listing logic functions' }); | ||
} | ||
|
@@ -46,9 +52,129 @@ module.exports = class LogicFunctionsCommand extends CLICommandBase { | |
console.log(org, name, id); | ||
} | ||
|
||
async create({ org, params: { filepath } }) { | ||
// TODO | ||
console.log(org, filepath); | ||
async create({ org, name, params : { filepath } } = { params: { } }) { | ||
const orgName = getOrgName(org); | ||
const api = createAPI(); | ||
// get name from filepath | ||
if (!filepath) { | ||
// use default directory | ||
filepath = '.'; | ||
} | ||
if (!name) { | ||
const question = { | ||
type: 'input', | ||
name: 'name', | ||
message: 'What would you like to call your Function?' | ||
}; | ||
const result = await this.ui.prompt([question]); | ||
name = result.name; | ||
} | ||
// trim name | ||
name = name.trim(); | ||
// ask for description | ||
const question = { | ||
type: 'input', | ||
name: 'description', | ||
message: 'Add a description for your Function (optional)' | ||
}; | ||
const result = await this.ui.prompt([question]); | ||
const description = result.description; | ||
const slugName = name.toLowerCase().replace(/\s/g, '-'); | ||
const destinationPath = path.join(filepath, slugName); | ||
|
||
this.ui.stdout.write(`Creating Logic Function ${this.ui.chalk.bold(name)} for ${orgName}...${os.EOL}`); | ||
await this._validateExistingName({ api, org, name }); | ||
await this._validateExistingFiles({ templatePath: logicFunctionTemplatePath, destinationPath }); | ||
const createdFiles = await this._copyAndReplaceLogicFunction({ | ||
logicFunctionName: name, | ||
logicFunctionSlugName: slugName, | ||
description, | ||
templatePath: logicFunctionTemplatePath, | ||
destinationPath: path.join(filepath, slugName) | ||
}); | ||
this.ui.stdout.write(`Successfully created ${this.ui.chalk.bold(name)} in ${this.ui.chalk.bold(filepath)}${os.EOL}`); | ||
this.ui.stdout.write(`Files created:${os.EOL}`); | ||
createdFiles.forEach((file) => { | ||
this.ui.stdout.write(`- ${file}${os.EOL}`); | ||
}); | ||
this.ui.stdout.write(`${os.EOL}Guidelines for creating your Logic Function can be found <TBD>.${os.EOL}`); | ||
this.ui.stdout.write(`Once you have written your Logic Function, run${os.EOL}`); | ||
this.ui.stdout.write(`- \`particle logic execute\` to run your Function${os.EOL}`); | ||
this.ui.stdout.write(`- \`particle logic deploy\` to deploy your new changes${os.EOL}`); | ||
return createdFiles; | ||
} | ||
|
||
async _validateExistingName({ api, org, name }) { | ||
// TODO (hmontero): request for a getLogicFunctionByName() method in the API | ||
let existingLogicFunction; | ||
try { | ||
const logicFunctionsResponse = await api.getLogicFunctionList({ org }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMO, This can directly use the Until we may have an API to get the name and id separately, I added this internal function to fetch the full list of the logic functions, and obtain the I made the above changes in the future branch: https://github.com/particle-iot/particle-cli/tree/feature/logic-function-get |
||
const existingLogicFunctions = logicFunctionsResponse.logic_functions; | ||
existingLogicFunction = existingLogicFunctions.find((item) => item.name === name); | ||
} catch (error) { | ||
this.ui.stdout.write(this.ui.chalk.yellow(`Warn: We were unable to check if a Logic Function with name ${name} already exists.${os.EOL}`)); | ||
} | ||
if (existingLogicFunction) { | ||
throw new Error(`Error: Logic Function with name ${name} already exists.`); | ||
} | ||
} | ||
|
||
async _validateExistingFiles({ templatePath, destinationPath }){ | ||
const filesAlreadyExist = await templateProcessor.hasTemplateFiles({ | ||
templatePath, | ||
destinationPath | ||
}); | ||
if (filesAlreadyExist) { | ||
const question = { | ||
type: 'confirm', | ||
name: 'overwrite', | ||
message: `We found existing files in ${this.ui.chalk.bold(destinationPath)}. Would you like to overwrite them?` | ||
}; | ||
const { overwrite } = await this.ui.prompt([question]); | ||
if (!overwrite) { | ||
this.ui.stdout.write(`Aborted.${os.EOL}`); | ||
process.exit(0); | ||
} | ||
} | ||
} | ||
|
||
/** Recursively copy and replace template files */ | ||
async _copyAndReplaceLogicFunction({ logicFunctionName, logicFunctionSlugName, description, templatePath, destinationPath }){ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can be refactored. I'll do that in further branches |
||
const files = await fs.readdir(templatePath); | ||
const createdFiles = []; | ||
|
||
for (const file of files){ | ||
//createdFiles.push(destinationFile); | ||
// check if file is a dir | ||
const stat = await fs.stat(path.join(templatePath, file)); | ||
if (stat.isDirectory()) { | ||
const subFiles = await this._copyAndReplaceLogicFunction({ | ||
logicFunctionName, | ||
logicFunctionSlugName, | ||
description, | ||
templatePath: path.join(templatePath, file), | ||
destinationPath: path.join(destinationPath, file) | ||
}); | ||
createdFiles.push(...subFiles); | ||
} else { | ||
const fileReplacements = { | ||
'logic_function_name': logicFunctionSlugName, | ||
}; | ||
const destinationFile = await templateProcessor.copyAndReplaceTemplate({ | ||
fileNameReplacements: fileReplacements, | ||
file, | ||
templatePath, | ||
destinationPath, | ||
replacements: { | ||
name: logicFunctionName, | ||
description: description || '' | ||
} | ||
}); | ||
createdFiles.push(destinationFile); | ||
} | ||
} | ||
// return file name created | ||
return createdFiles; | ||
} | ||
|
||
async execute({ org, data, params: { filepath } }) { | ||
|
@@ -92,7 +218,7 @@ function createAPIErrorResult({ error: e, message, json }){ | |
|
||
// get org name from org slug | ||
function getOrgName(org) { | ||
return org || 'Staging'; | ||
return org || 'Sandbox'; | ||
} | ||
|
||
// TODO (mirande): reconcile this w/ `normalizedApiError()` and `ensureError()` | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the slugify function from api-service. Can you use that? Copy it to a helper
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done here! 👉 348a583