stillmade-sdk 0.1.47 → 0.1.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -2
- package/FRAME_VIEWS.md +71 -5
- package/INTERFACE_DESIGN.md +65 -2
- package/MODULES.md +26 -3
- package/PASTE_TO_LLM.md +25 -10
- package/README.md +15 -0
- package/SDK.md +349 -51
- package/blocks/native/sm.board-editor/versions/3.0.0/package.js +921 -0
- package/blocks/native/sm.editor/versions/3.0.0/package.js +751 -0
- package/blocks/native/sm.panel-strip/versions/3.0.0/package.js +350 -0
- package/blocks/native/sm.photo-editor/versions/1.0.0/package.js +6693 -0
- package/blocks/native/sm.voiceover/versions/3.0.0/package.js +513 -0
- package/docs/build/block-builder-contract.md +2 -2
- package/docs/build/custom-interface.md +23 -3
- package/docs/build/frame-views.md +71 -5
- package/docs/build/hosted-text.md +8 -3
- package/docs/build/interface-design.md +65 -2
- package/docs/build/media-analysis.md +18 -1
- package/docs/build/modules.md +26 -3
- package/docs/build/with-your-ai.md +14 -0
- package/docs/build/workspaces.md +42 -0
- package/docs/reference/authoring-guide.md +4 -4
- package/docs/reference/cli.md +8 -3
- package/docs/reference/execution.md +13 -5
- package/docs/reference/first-party-blocks.md +5 -4
- package/docs/reference/generation-models.md +4 -0
- package/docs/reference/limits.md +9 -2
- package/docs/reference/permissions.md +4 -0
- package/docs/reference/view-bridge.md +6 -3
- package/docs/reference/view-rules.md +1 -1
- package/docs/tools/llm.md +25 -10
- package/package.json +1 -1
- package/packages/block-cli/cli.js +124 -12
- package/packages/block-cli/dev-host/assets/host.js +55 -30
- package/packages/block-cli/dev-host/assets/index.css +1 -1
- package/packages/block-cli/dev-server.js +74 -23
- package/packages/block-cli/format.js +20 -1
- package/packages/block-cli/ready-gate.js +34 -2
- package/packages/block-cli/templates.js +61 -0
- package/packages/block-cli/type-packages.js +7 -4
- package/packages/block-cli/workspace-dev.js +124 -0
- package/packages/block-cli/workspace-pack.js +62 -0
- package/packages/block-cli/workspace.js +304 -0
- package/packages/block-platform/block-readiness.js +5 -4
- package/packages/block-platform/catalog.js +5 -3
- package/packages/block-platform/node-defaults.js +2 -2
- package/packages/block-platform/plain-descriptions.js +1 -0
- package/packages/block-platform/type-workspace.js +8 -0
- package/packages/block-sdk/admission.js +2 -2
- package/packages/block-sdk/builder-contract.js +3 -3
- package/packages/block-sdk/capabilities.js +32 -12
- package/packages/block-sdk/contracts.js +11 -2
- package/packages/block-sdk/first-party-identities.js +9 -0
- package/packages/block-sdk/first-party-packages.js +9 -1
- package/packages/block-sdk/folder-package.js +34 -2
- package/packages/block-sdk/generation-catalog.js +7 -0
- package/packages/block-sdk/index.d.ts +13 -5
- package/packages/block-sdk/index.js +1 -1
- package/packages/block-sdk/mock-host.js +7 -0
- package/packages/block-sdk/module-hosted.js +11 -1
- package/packages/block-sdk/module-media-access.js +5 -3
- package/packages/block-sdk/permission-summary.js +1 -1
- package/packages/block-sdk/portable-media-actions.d.ts +4 -1
- package/packages/block-sdk/portable-media-actions.js +16 -1
- package/packages/block-sdk/remix-policy.js +2 -2
- package/packages/block-sdk/staged-releases.js +5 -0
- package/packages/block-sdk/stillmade-pack.js +64 -0
- package/packages/block-sdk/testing.js +2 -2
- package/packages/block-sdk/view-bridge.js +6 -3
- package/packages/block-sdk/view-design.js +59 -12
- package/packages/block-sdk/view.js +47 -9
- package/packages/design-system/block-kit.css +6 -0
- package/packages/design-system/theme.css +2 -0
|
@@ -10,6 +10,15 @@ Start
|
|
|
10
10
|
create <folder> [template] Start a Block from a template (create --list shows all)
|
|
11
11
|
create-type <file.json> Start a Project Type
|
|
12
12
|
|
|
13
|
+
Several Blocks in one folder (a workspace)
|
|
14
|
+
init <folder> [--name "Listing kit"] Start a workspace: blocks/, shared/, workflows/ and the agent guides
|
|
15
|
+
add <name> [template] Add blocks/<name> from a template (--app gmail with the connector template)
|
|
16
|
+
add-type <name> [block ...] Add workflows/<name>.json, a Project Type made of the workspace's Blocks
|
|
17
|
+
(--when schedule starts it with StillMade's When Step: an automation)
|
|
18
|
+
build Join src/main.js and shared/ code into each module Block's module/main.js
|
|
19
|
+
From the workspace folder, dev, test, validate, gate and pack cover every Block and workflow in it;
|
|
20
|
+
pack writes one .stillmade-pack file for StillMade's Import.
|
|
21
|
+
|
|
13
22
|
Build and check
|
|
14
23
|
validate [folder] Check the manifest, source and interface (--mobile adds the phone profile)
|
|
15
24
|
test [folder] Run tests/fixtures.json
|
|
@@ -69,6 +78,13 @@ export function formatResult(args,result){
|
|
|
69
78
|
const width=Math.max(...result.templates.map(item=>item.name.length));
|
|
70
79
|
return lines('Templates (stillmade-block create <folder> <template>):','',...result.templates.map(item=>` ${item.name.padEnd(width)} ${item.description}`));
|
|
71
80
|
}
|
|
81
|
+
// A workspace's own results (workspace.js).
|
|
82
|
+
if(command==='init'&&result?.kind==='workspace')return lines(`Started the workspace "${result.name}" in ${result.created}.`,'',`Next:`,` cd ${result.created}`,' npm install',` npx stillmade-block add <name> [template]`,' npx stillmade-block dev');
|
|
83
|
+
if(command==='add'&&result?.block)return lines(`Added blocks/${result.block} from the ${result.template} template.`,result.source?`Edit its code in blocks/${result.block}/${result.source} only: module/main.js is a copy that build, test, gate and pack write from it.`:null,'A running preview picks it up.');
|
|
84
|
+
if(command==='add-type'&&result?.kind==='workflow')return lines(`Added ${relative(process.cwd(),result.created)||result.created}: ${result.steps.join(' -> ')}, ${result.connections} ${result.connections===1?'connection':'connections'} where the ports agree.`,result.next);
|
|
85
|
+
if(command==='build'&&result?.workspace)return lines(result.built.length?`Built ${result.built.join(', ')}.`:'Nothing to build: every module/main.js is current.',...Object.entries(result.shared||{}).filter(([,files])=>files.length).map(([block,files])=>` ${block} uses shared/${files.join(', shared/')}`));
|
|
86
|
+
if(result?.workspace&&Array.isArray(result.results)&&result.command)return lines(`${result.results.length} of ${result.results.length} passed ${result.command}.`,...result.results.map(item=>` PASS ${item.item}${item.tests!==undefined?` (${item.tests} ${item.tests===1?'test':'tests'})`:''}`));
|
|
87
|
+
if(command==='pack'&&result?.pack)return lines(`Wrote ${result.pack} (${result.items.filter(item=>item.kind!=='workflow').length} Blocks, ${result.items.filter(item=>item.kind==='workflow').length} ${result.items.filter(item=>item.kind==='workflow').length===1?'workflow':'workflows'}).`,...result.items.map(item=>` ${item.kind.padEnd(10)} ${item.id}@${item.version}`),'Import it in StillMade: Create, then Import. StillMade asks which ones to bring in.');
|
|
72
88
|
if((command==='create'||command==='create-type')&&result?.created){
|
|
73
89
|
if(result.kind==='type')return lines(`Created a Project Type at ${result.created}.`);
|
|
74
90
|
const cli=cliCommand(result.created);
|
|
@@ -99,7 +115,10 @@ export function formatResult(args,result){
|
|
|
99
115
|
export function formatError(error){
|
|
100
116
|
const out=[`Error: ${error?.message||error}${error?.code?` (${error.code})`:''}`];
|
|
101
117
|
const report=error?.report;
|
|
102
|
-
if(Array.isArray(report
|
|
118
|
+
if(report?.workspace&&Array.isArray(report.results)){
|
|
119
|
+
for(const item of report.results)out.push(item.passed?` PASS ${item.item}`:` FAIL ${item.item}: ${indent(item.message||'failed',' ').trimStart()}`);
|
|
120
|
+
}else if(Array.isArray(report?.failed)&&report.failed.length&&error?.code==='BUILD')out.push(...report.failed.map(item=>` ${item.block}: ${item.message}`));
|
|
121
|
+
else if(Array.isArray(report?.results)){
|
|
103
122
|
for(const item of report.results)out.push(item.passed?` PASS ${item.name}`:` FAIL ${item.name}: ${item.message||item.code||'failed'}`);
|
|
104
123
|
out.push(...notes(report));
|
|
105
124
|
}else if(Array.isArray(report?.findings))out.push(...report.findings.map(item=>` ${item.message||pretty(item)}`));
|
|
@@ -128,6 +128,33 @@ async function judge(path,main,{executeModules=true}={}){
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
async function workspaceGate(root){
|
|
132
|
+
const empty={workflows:[],problems:new Map(),blocksDigest:''};
|
|
133
|
+
const {isWorkspaceRoot,workspaceItems,buildWorkspace,unbundledImport}=await import('./workspace.js');
|
|
134
|
+
if(!(await isWorkspaceRoot(root)))return empty;
|
|
135
|
+
const problems=new Map();
|
|
136
|
+
let build={failed:[]};
|
|
137
|
+
try{build=await buildWorkspace(root);}catch(error){const {blocks}=await workspaceItems(root);for(const item of blocks)if(item.source)problems.set(item.path,error.message);}
|
|
138
|
+
const {blocks,workflows}=await workspaceItems(root);
|
|
139
|
+
for(const item of blocks){
|
|
140
|
+
const failed=build.failed.find(entry=>entry.block===item.name);if(failed){problems.set(item.path,`Build failed: ${failed.message}`);continue;}
|
|
141
|
+
const loose=await unbundledImport(item.path);if(loose)problems.set(item.path,`${loose.file} imports ${loose.specifier}. A Block runs one file: keep the code in src/main.js and run \`stillmade-block build\`.`);
|
|
142
|
+
}
|
|
143
|
+
return {workflows:workflows.map(item=>item.path),problems,blocksDigest:await blocksDigest(blocks)};
|
|
144
|
+
}
|
|
145
|
+
async function blocksDigest(blocks){const digests=[];for(const item of blocks){try{digests.push(item.name,await packageDigest(item.path));}catch{}}return sha256(digests.join('\0'));}
|
|
146
|
+
/** The digest the gate keys each workspace item's verdict by ({'blocks/x': digest, 'workflows/y.json': digest}), and the cached verdicts. */
|
|
147
|
+
export async function workspaceVerdicts(root){
|
|
148
|
+
const {workspaceItems}=await import('./workspace.js');
|
|
149
|
+
const {blocks,workflows}=await workspaceItems(root),digests={},all=await blocksDigest(blocks);
|
|
150
|
+
for(const item of blocks){try{digests[posix(relative(root,item.path))]=await packageDigest(item.path);}catch{}}
|
|
151
|
+
for(const item of workflows){try{digests[posix(relative(root,item.path))]=sha256(String(await packageDigest(item.path))+all);}catch{}}
|
|
152
|
+
const cache=await readJson(join(root,READY_GATE_STATE,'ready-cache.json'),{entries:{}});
|
|
153
|
+
const verdicts={};
|
|
154
|
+
for(const [name,digest] of Object.entries(digests)){const entry=cache.entries?.[name];verdicts[name]=entry?{current:entry.digest===digest&&entry.contract===BUILDER_CONTRACT_STAMP,ok:!!entry.ok,status:entry.status,failures:entry.failures||[],checkedAt:entry.checkedAt}:null;}
|
|
155
|
+
return verdicts;
|
|
156
|
+
}
|
|
157
|
+
|
|
131
158
|
/** Judge every changed package. Never throws for a failing Block; returns a verdict. */
|
|
132
159
|
export async function runReadyGate({root=process.cwd(),main,executeModules=true}={}){
|
|
133
160
|
root=resolve(root);
|
|
@@ -135,8 +162,13 @@ export async function runReadyGate({root=process.cwd(),main,executeModules=true}
|
|
|
135
162
|
const baselines=new Map();
|
|
136
163
|
const cachePath=join(root,READY_GATE_STATE,'ready-cache.json'),cache=await readJson(cachePath,{version:1,entries:{}});
|
|
137
164
|
const results=[];
|
|
138
|
-
|
|
139
|
-
|
|
165
|
+
// A workspace (workspace.js): module Blocks are built from src/main.js first, and its workflows are judged too,
|
|
166
|
+
// each against the Blocks it names, so a changed Block judges its workflows again.
|
|
167
|
+
const workspace=await workspaceGate(root),packages=[...await findPackages(root),...workspace.workflows];
|
|
168
|
+
for(const path of packages){
|
|
169
|
+
const name=posix(relative(root,path)),digest=workspace.workflows.includes(path)?sha256(String(await packageDigest(path))+workspace.blocksDigest):await packageDigest(path);
|
|
170
|
+
const early=workspace.problems.get(path);
|
|
171
|
+
if(early){results.push({package:name,digest,ok:false,status:'FAILED',failures:[early],limitations:0});continue;}
|
|
140
172
|
if(await shippedDigest(root,path,baselines)===digest)continue;
|
|
141
173
|
const cached=cache.entries[name];
|
|
142
174
|
// A verdict is reused only for the same package under the same contract: a newer SDK judges again
|
|
@@ -222,6 +222,58 @@ export default async function run(input, stillmade) {
|
|
|
222
222
|
return { title: (title?.title || []).map((part) => part.plain_text || '').join('').trim() || 'Untitled page' };
|
|
223
223
|
}
|
|
224
224
|
`;
|
|
225
|
+
// Connector examples: the action StillMade's connections service names for each app (packages/block-platform/automation-templates.js
|
|
226
|
+
// uses the same names), what it takes, and an answer recorded for offline tests.
|
|
227
|
+
const connectorApp=value=>{const app=String(value||'gmail').toLowerCase().replace(/^app:/,'');if(!/^[a-z0-9][a-z0-9-]{0,79}$/.test(app))throw Object.assign(new Error('--app takes an app name, such as gmail, slack or notion.'),{code:'USAGE'});return app;};
|
|
228
|
+
const CONNECTOR_EXAMPLES={
|
|
229
|
+
gmail:{label:'Gmail',name:'Gmail digest',operation:'GMAIL_FETCH_EMAILS',action:{label:'Make digest',running:'Reading your email…'},description:'Lists the subjects and senders of the emails that match a search.',
|
|
230
|
+
inputs:{query:{type:'text',primary:true,required:true,semantic:'email_search',description:'A Gmail search, such as from:listings newer_than:1d.'}},outputs:{digest:{type:'text',primary:true,semantic:'email_digest',description:'One line per email: the subject and who sent it.'}},ui:[{control:'text',port:'query',label:'Search'}],
|
|
231
|
+
code:`// Runs Gmail's GMAIL_FETCH_EMAILS action with the account the person connected. Locally, tests/mocks.json answers.
|
|
232
|
+
export default async function run(input, stillmade) {
|
|
233
|
+
const result = await stillmade.connections.call('app:gmail', 'GMAIL_FETCH_EMAILS', { query: input.query, max_results: 10 });
|
|
234
|
+
const messages = result?.messages || result?.data?.messages || [];
|
|
235
|
+
const lines = messages.map((message) => \`\${message.subject || '(no subject)'} · \${message.sender || message.from || 'unknown sender'}\`);
|
|
236
|
+
return { digest: lines.length ? lines.join('\\n') : 'No emails match this search.' };
|
|
237
|
+
}
|
|
238
|
+
`,test:'Two emails',input:{query:'from:listings newer_than:1d'},answer:{messages:[{subject:'Price drop: 22 Ridge Rd',sender:'alerts@listings.example'},{subject:'New listing: 1418 Juniper Lane',sender:'alerts@listings.example'}]},
|
|
239
|
+
expected:{digest:'Price drop: 22 Ridge Rd · alerts@listings.example\nNew listing: 1418 Juniper Lane · alerts@listings.example'}},
|
|
240
|
+
slack:{label:'Slack',name:'Slack post',operation:'SLACK_SEND_MESSAGE',action:{label:'Post message',running:'Posting to Slack…'},description:'Posts a message to a Slack channel.',
|
|
241
|
+
inputs:{channel:{type:'text',required:true,semantic:'slack_channel',description:'The channel, such as #listings.'},text:{type:'text',primary:true,required:true,semantic:'message_text',description:'What to post.'}},outputs:{posted:{type:'text',primary:true,semantic:'confirmation',description:'Where the message went.'}},ui:[{control:'text',port:'channel'},{control:'text',port:'text'}],
|
|
242
|
+
code:`// Runs Slack's SLACK_SEND_MESSAGE action with the account the person connected. Locally, tests/mocks.json answers.
|
|
243
|
+
export default async function run(input, stillmade) {
|
|
244
|
+
const result = await stillmade.connections.call('app:slack', 'SLACK_SEND_MESSAGE', { channel: input.channel, text: input.text });
|
|
245
|
+
return { posted: result?.ok === false ? 'Slack did not post the message.' : \`Posted to \${input.channel}\` };
|
|
246
|
+
}
|
|
247
|
+
`,test:'Posts',input:{channel:'#listings',text:'22 Ridge Rd dropped 3%.'},answer:{ok:true},expected:{posted:'Posted to #listings'}},
|
|
248
|
+
notion:{label:'Notion',name:'Notion page',operation:'NOTION_CREATE_NOTION_PAGE',action:{label:'Add page',running:'Adding the page…'},description:'Adds a page with a title under a Notion page.',
|
|
249
|
+
inputs:{parent:{type:'text',required:true,semantic:'notion_page_id',description:'The Notion page to add it under.'},title:{type:'text',primary:true,required:true,semantic:'page_title',description:'The new page\'s title.'}},outputs:{page:{type:'text',primary:true,semantic:'notion_page_id',description:'The new page.'}},ui:[{control:'text',port:'parent'},{control:'text',port:'title'}],
|
|
250
|
+
code:`// Runs Notion's NOTION_CREATE_NOTION_PAGE action with the account the person connected. Locally, tests/mocks.json answers.
|
|
251
|
+
export default async function run(input, stillmade) {
|
|
252
|
+
const result = await stillmade.connections.call('app:notion', 'NOTION_CREATE_NOTION_PAGE', { parent_id: input.parent, title: input.title });
|
|
253
|
+
return { page: result?.id || result?.data?.id || '' };
|
|
254
|
+
}
|
|
255
|
+
`,test:'Adds a page',input:{parent:'b1b2c3d4e5f60718293a4b5c6d7e8f90',title:'Listing notes'},answer:{id:'c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4'},expected:{page:'c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4'}},
|
|
256
|
+
googlesheets:{label:'Google Sheets',name:'Sheet rows',operation:'GOOGLESHEETS_GET_ROWS',action:{label:'Read rows',running:'Reading the sheet…'},description:'Reads the rows of a Google Sheet.',
|
|
257
|
+
inputs:{sheet:{type:'text',primary:true,required:true,semantic:'spreadsheet_id',description:'The spreadsheet ID from its address.'}},outputs:{rows:{type:'json',primary:true,semantic:'table_rows',description:'The rows, each a list of cell values.'}},ui:[{control:'text',port:'sheet'}],
|
|
258
|
+
code:`// Runs Google Sheets' GOOGLESHEETS_GET_ROWS action with the account the person connected. Locally, tests/mocks.json answers.
|
|
259
|
+
export default async function run(input, stillmade) {
|
|
260
|
+
const result = await stillmade.connections.call('app:googlesheets', 'GOOGLESHEETS_GET_ROWS', { spreadsheet_id: input.sheet });
|
|
261
|
+
return { rows: result?.values || result?.data?.values || [] };
|
|
262
|
+
}
|
|
263
|
+
`,test:'Two rows',input:{sheet:'1AbCdEfGhIjKlMnOpQrStUvWxYz'},answer:{values:[['Address','Price'],['22 Ridge Rd','$412,000']]},expected:{rows:[['Address','Price'],['22 Ridge Rd','$412,000']]}},
|
|
264
|
+
};
|
|
265
|
+
function genericConnector(app){
|
|
266
|
+
const label=app[0].toUpperCase()+app.slice(1),operation=`${app.toUpperCase().replace(/-/g,'_')}_ACTION_NAME`;
|
|
267
|
+
return {generic:true,label,name:`${label} action`,operation,action:{label:`Run ${label} action`,running:`Running the ${label} action…`},description:`Runs one ${label} action with the person's connected account.`,
|
|
268
|
+
inputs:{request:{type:'object',primary:true,required:true,semantic:'action_input',description:`What the ${label} action takes.`}},outputs:{result:{type:'object',primary:true,semantic:'action_result',description:`What ${label} answered.`}},ui:[],
|
|
269
|
+
code:`// Runs one ${label} action with the account the person connected. Put the action's name from the app's list of actions
|
|
270
|
+
// in place of ${operation}, here and in tests/mocks.json. Locally, tests/mocks.json answers.
|
|
271
|
+
export default async function run(input, stillmade) {
|
|
272
|
+
const result = await stillmade.connections.call('app:${app}', '${operation}', input.request);
|
|
273
|
+
return { result: result && typeof result === 'object' ? result : { value: result } };
|
|
274
|
+
}
|
|
275
|
+
`,test:'Answers',input:{request:{}},answer:{ok:true},expected:{result:{ok:true}}};
|
|
276
|
+
}
|
|
225
277
|
const STORAGE=`// A module Block that remembers settings in the project's Block storage
|
|
226
278
|
// (manifest storage.cloud "metered"). Values persist across runs, devices and
|
|
227
279
|
// new versions of the Block. Locally, storage is kept in memory per run.
|
|
@@ -283,6 +335,15 @@ const STARTERS={
|
|
|
283
335
|
'tests/mocks.json':json({connections:{'app:openapi:notion/retrievePage':{object:'page',properties:{Name:{type:'title',title:[{plain_text:'Launch plan'}]}}}}}),
|
|
284
336
|
'README.md':readme(pkg.name,'A module Block that calls a published adapter. People sign in once per Block; tests/mocks.json records the adapter\'s answers for offline tests.',HOSTED,options)};
|
|
285
337
|
}},
|
|
338
|
+
// A Block of the person's own that calls a signed-in catalog app (Gmail, Slack, Notion, Google Sheets...) by its action
|
|
339
|
+
// name (owner, November 6, 2026: connector Blocks in a workspace). `add <name> connector --app gmail`.
|
|
340
|
+
connector:{description:'Call a signed-in app (Gmail, Slack, Notion, Google Sheets...) by its action name; --app picks the app.',runtime:'module',files:async options=>{
|
|
341
|
+
const app=connectorApp(options?.app),example=CONNECTOR_EXAMPLES[app]||genericConnector(app);
|
|
342
|
+
const pkg=manifest({id:`example.${app}-connector`,action:example.action,version:'1.0.0',name:example.name,description:example.description,runtime:'module',inputs:example.inputs,outputs:example.outputs,permissions:{connections:[`app:${app}`]},ui:example.ui});
|
|
343
|
+
return {'stillmade.block.json':json(pkg),'module/main.js':example.code,'tests/fixtures.json':json([{name:example.test,input:example.input,expected:example.expected}]),
|
|
344
|
+
'tests/mocks.json':json({connections:{[`app:${app}/${example.operation}`]:example.answer}}),
|
|
345
|
+
'README.md':readme(pkg.name,`A module Block that runs ${example.label}'s ${example.operation} action with the account the person connects in StillMade. It asks them to connect ${example.label} on its first run and shows the price of each action before it runs; the account never reaches the Block. tests/mocks.json records the app's answer for offline tests.${example.generic?` ${example.operation} is a placeholder: put the action name from the app's list of actions in module/main.js and tests/mocks.json.`:''}`,HOSTED,options)};
|
|
346
|
+
}},
|
|
286
347
|
storage:{description:'Remember values across runs in the project\'s Block storage.',runtime:'module',files:async options=>{
|
|
287
348
|
const pkg=manifest({id:'example.remember-settings',action:{label:'Save settings',running:'Saving settings…'},version:'1.0.0',name:'Remember settings',description:'Keeps a name and a run count in project storage.',runtime:'module',storage:{schemaVersion:1,scope:'project',desktop:'device-unmetered',cloud:'metered'},inputs:{name:{type:'text',primary:true,required:false,semantic:'person_name',description:'A name to remember.'}},outputs:{greeting:{type:'text',primary:true,semantic:'greeting',description:'A greeting with the run count.'}},ui:[{control:'text',port:'name'}]});
|
|
288
349
|
return {'stillmade.block.json':json(pkg),'module/main.js':STORAGE,'tests/fixtures.json':json([{name:'First run',input:{name:'Ada'},expected:{greeting:'Hello Ada, run 1'}}]),'tests/mocks.json':json({storage:{}}),'README.md':readme(pkg.name,'A module Block with project storage. tests/mocks.json can seed stored values under "storage".',OFFLINE,options)};
|
|
@@ -85,23 +85,26 @@ const sourceFiles=pkg=>{
|
|
|
85
85
|
* mocks when it records some, else by the stand-ins with --mock. No provider is ever called.
|
|
86
86
|
* Returns {test, run} for testProjectType/previewProjectType and calls() for the report.
|
|
87
87
|
*/
|
|
88
|
-
|
|
88
|
+
// mocksFor(pkg): the Block's own recorded answers (a workspace Block's tests/mocks.json), used before --mock's stand-ins.
|
|
89
|
+
export function typeModuleRunner({mock=false,readLocalFile,mocksFor=()=>undefined}={}){
|
|
89
90
|
const hosts=[];
|
|
90
91
|
const hostFor=(pkg,mocks,source)=>{const host=createMockHost({mocks:validateMocks(mocks),readLocalFile,manifest:pkg.manifest});hosts.push({host,source});return host.host;};
|
|
91
92
|
const executor=(pkg,host)=>createNodeModuleExecutor({files:sourceFiles(pkg),...(host?{host}:{})});
|
|
92
93
|
const test=(pkg,options={})=>{
|
|
93
94
|
if(pkg.manifest.runtime!=='module')return testPackage(pkg,options);
|
|
94
|
-
const module=executor(pkg,mock?hostFor(pkg,undefined,'recorded stand-in'):undefined);
|
|
95
|
+
const own=mocksFor(pkg),module=executor(pkg,own?hostFor(pkg,own,'tests/mocks.json'):mock?hostFor(pkg,undefined,'recorded stand-in'):undefined);
|
|
95
96
|
return testPackage(pkg,{...options,module,moduleFor:fixture=>fixture?.mocks?executor(pkg,hostFor(pkg,fixture.mocks,'fixture mocks')):module,timeoutMs:Math.min(options.timeoutMs??MODULE_LIMITS.wallClockMs,MODULE_LIMITS.wallClockMs)});
|
|
96
97
|
};
|
|
97
98
|
const run=(pkg,input,options={})=>{
|
|
98
99
|
if(pkg.manifest.runtime!=='module')return runPackageAsync(pkg,input,options);
|
|
99
100
|
const recorded=pkg.tests?.find(fixture=>fixture?.mocks)?.mocks;
|
|
100
|
-
const host=recorded?hostFor(pkg,recorded,'fixture mocks'):mock?hostFor(pkg,undefined,'recorded stand-in'):undefined;
|
|
101
|
+
const own=mocksFor(pkg),host=recorded?hostFor(pkg,recorded,'fixture mocks'):own?hostFor(pkg,own,'tests/mocks.json'):mock?hostFor(pkg,undefined,'recorded stand-in'):undefined;
|
|
101
102
|
return runPackageAsync(pkg,input,{...options,module:executor(pkg,host)});
|
|
102
103
|
};
|
|
103
104
|
const calls=()=>hosts.flatMap(({host,source})=>host.calls.map(call=>({api:call.api,name:call.name,source:call.source==='mocks'?source:'recorded stand-in'})));
|
|
104
|
-
|
|
105
|
+
// Media a stand-in made during a run, for the workspace preview's workflow result.
|
|
106
|
+
const file=url=>{for(const {host} of hosts){const made=host.file?.(url);if(made)return made;}return null;};
|
|
107
|
+
return {test,run,calls,file};
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
// What `create-type` writes: one job Step, then the Editor, which has captions and export built in, so no
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// What `stillmade-block dev` serves for a workspace (dev-server.js `workspace`): the side list of every Block, workflow
|
|
2
|
+
// and shared file with its state, each Block's own preview session, follow-the-agent, the workflow run with stand-ins
|
|
3
|
+
// and the readiness gate (owner, November 6, 2026). Nothing here reaches StillMade or a provider.
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {readFile} from 'node:fs/promises';
|
|
6
|
+
import {blockKind,buildWorkspace,readWorkspaceState,workspaceItems,findWorkspace,folderName,adoptWorkspaceBlock,BLOCKS_DIR,WORKFLOWS_DIR,SHARED_DIR} from './workspace.js';
|
|
7
|
+
import {runReadyGate,workspaceVerdicts} from './ready-gate.js';
|
|
8
|
+
import {LICENSE_HOLDER_PLACEHOLDER,TEMPLATES} from './templates.js';
|
|
9
|
+
import {blockSession,loadProjectType,localFileReader,typeMocksFor} from './cli.js';
|
|
10
|
+
import {typeModuleRunner} from './type-packages.js';
|
|
11
|
+
import {previewProjectType} from '../block-platform/type-preview.js';
|
|
12
|
+
import {packageForStage} from '../block-platform/stage-package.js';
|
|
13
|
+
import {briefInputNames} from '../block-platform/brief-ports.js';
|
|
14
|
+
import {BUILTIN_BLOCKS} from '../block-platform/catalog.js';
|
|
15
|
+
import {describeTrigger} from '../block-sdk/triggers.js';
|
|
16
|
+
|
|
17
|
+
const NAME=/^[a-z][a-z0-9-]{0,59}$/;
|
|
18
|
+
const PLACEHOLDER_DESCRIPTION=/^Replace with one sentence/;
|
|
19
|
+
// The starting feature map `create` writes (one entry copied from the description) does not count until rewritten (ready-gate.js).
|
|
20
|
+
const placeholderFeatures=features=>!Array.isArray(features)||!features.length||features.length===1&&features[0].id==='main'&&features[0].asks?.length===1&&features[0].asks[0]===features[0].does;
|
|
21
|
+
const portList=ports=>Object.entries(ports||{}).map(([name,port])=>({name,type:port?.type||'json',...(port?.required?{required:true}:{})}));
|
|
22
|
+
|
|
23
|
+
export async function workspaceProvider(root,{main,picks}){
|
|
24
|
+
const sessions=new Map();let lastRunner=null;
|
|
25
|
+
const blockPath=key=>{const name=key.slice(BLOCKS_DIR.length+1);return key.startsWith(`${BLOCKS_DIR}/`)&&NAME.test(name)?join(root,BLOCKS_DIR,name):null;};
|
|
26
|
+
const workflowPath=key=>{const name=key.slice(WORKFLOWS_DIR.length+1);return key.startsWith(`${WORKFLOWS_DIR}/`)&&NAME.test(name)?join(root,WORKFLOWS_DIR,`${name}.json`):null;};
|
|
27
|
+
|
|
28
|
+
async function list(){
|
|
29
|
+
const [{config},{blocks,workflows,shared},state,verdicts]=await Promise.all([findWorkspace(root),workspaceItems(root),readWorkspaceState(root),workspaceVerdicts(root)]);
|
|
30
|
+
const usedBy={};for(const [block,files] of Object.entries(state.shared||{}))for(const file of files)(usedBy[file]||=[]).push(block);
|
|
31
|
+
const items=[];
|
|
32
|
+
for(const item of blocks){
|
|
33
|
+
const key=`${BLOCKS_DIR}/${item.name}`,manifest=item.manifest||{},gate=verdicts[key],built=state.buildFailures?.[item.name];
|
|
34
|
+
const checklist=[
|
|
35
|
+
{id:'description',label:'Description says what it does for the person',done:!!manifest.description&&!PLACEHOLDER_DESCRIPTION.test(manifest.description)},
|
|
36
|
+
{id:'license',label:'License names who it is made for',done:!String(manifest.provenance?.notice||'').includes(LICENSE_HOLDER_PLACEHOLDER)},
|
|
37
|
+
{id:'features',label:'Feature map: one entry per thing it can do',done:!placeholderFeatures(manifest.features)},
|
|
38
|
+
];
|
|
39
|
+
const status=item.problem||built||gate?.current&&!gate.ok?'needs-fix':gate?.current&&gate.ok?'ready':checklist.some(entry=>!entry.done)?'building':'unchecked';
|
|
40
|
+
items.push({key,kind:'block',folder:item.name,name:manifest.name||item.name,version:manifest.version||null,runtime:manifest.runtime||null,blockKind:blockKind(manifest),
|
|
41
|
+
...(manifest.triggers?.length?{triggers:manifest.triggers.map(trigger=>{try{return describeTrigger(trigger);}catch{return String(trigger?.event||'');}})}:{}),
|
|
42
|
+
...(manifest.permissions?.connections?.length?{apps:manifest.permissions.connections}:{}),
|
|
43
|
+
status,checklist,...(item.problem?{problem:item.problem}:{}),...(built?{buildProblem:built}:{}),
|
|
44
|
+
source:item.source?'src/main.js':null,shared:state.shared?.[item.name]||[],gate:gate||null});
|
|
45
|
+
}
|
|
46
|
+
for(const item of workflows){
|
|
47
|
+
const key=`${WORKFLOWS_DIR}/${item.name}`,gate=verdicts[`${WORKFLOWS_DIR}/${item.name}.json`],stages=Array.isArray(item.type?.stages)?item.type.stages:[];
|
|
48
|
+
items.push({key,kind:'workflow',folder:item.name,name:item.type?.name||item.name,version:item.type?.version||null,steps:stages.length,automation:stages[0]?.blockId==='sm.when',
|
|
49
|
+
status:item.problem?'needs-fix':gate?.current?(gate.ok?'ready':'needs-fix'):'unchecked',...(item.problem?{problem:item.problem}:{}),gate:gate||null});
|
|
50
|
+
}
|
|
51
|
+
return {name:config.name,version:config.version,root,items,shared:shared.filter(file=>file!=='README.md').map(file=>({file,usedBy:usedBy[file]||[]}))};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Which Blocks a set of saved paths touches: what to build, what to reload, and which item the agent is working on.
|
|
55
|
+
async function changed(paths){
|
|
56
|
+
const plan={build:new Set(),reload:new Set(),focus:null},state=await readWorkspaceState(root);
|
|
57
|
+
let items=null;const blockItems=async()=>items||=(await workspaceItems(root)).blocks;
|
|
58
|
+
for(const path of paths){
|
|
59
|
+
const block=/^blocks\/([^/]+)\/(.+)$/.exec(path),workflow=/^workflows\/([^/]+)\.json$/.exec(path);
|
|
60
|
+
if(block){
|
|
61
|
+
const [,name,rest]=block;if(/^(node_modules|\.stillmade|dist)\//.test(rest))continue;
|
|
62
|
+
if(rest==='src/main.js')plan.build.add(name);
|
|
63
|
+
plan.reload.add(`${BLOCKS_DIR}/${name}`);plan.focus=`${BLOCKS_DIR}/${name}`;
|
|
64
|
+
}else if(workflow){plan.focus=`${WORKFLOWS_DIR}/${workflow[1].replace(/\.samples$/,'')}`;}
|
|
65
|
+
else if(path.startsWith(`${SHARED_DIR}/`)){
|
|
66
|
+
const file=path.slice(SHARED_DIR.length+1);
|
|
67
|
+
for(const item of await blockItems())if(item.source&&(!state.shared?.[item.name]||state.shared[item.name].includes(file))){plan.build.add(item.name);plan.reload.add(`${BLOCKS_DIR}/${item.name}`);}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for(const name of plan.build)plan.reload.add(`${BLOCKS_DIR}/${name}`);
|
|
71
|
+
return {build:[...plan.build],reload:[...plan.reload],focus:plan.focus};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The workflow with its Steps' ports, for the preview's Steps row, and the inputs a run starts from.
|
|
75
|
+
async function workflow(key){
|
|
76
|
+
const path=workflowPath(key);if(!path)throw new Error('No such workflow in this workspace.');
|
|
77
|
+
const raw=JSON.parse(await readFile(path,'utf8')),type=await loadProjectType(path);
|
|
78
|
+
const steps=type.stages.map((stage,index)=>{
|
|
79
|
+
const pkg=packageForStage(type,stage,BUILTIN_BLOCKS),manifest=pkg?.manifest||{};
|
|
80
|
+
return {id:stage.id,label:stage.label||manifest.name||stage.blockId,blockId:stage.blockId,version:stage.version,folder:raw.stages?.[index]?.block||null,name:manifest.name||stage.blockId,runtime:manifest.runtime||null,blockKind:blockKind(manifest),inputs:portList(manifest.inputs),outputs:portList(manifest.outputs)};
|
|
81
|
+
});
|
|
82
|
+
return {key,name:type.name,version:type.version,description:type.description||'',automation:type.stages[0]?.blockId==='sm.when',steps,connections:type.connections||[],samples:await samplesFor(path,type)};
|
|
83
|
+
}
|
|
84
|
+
// workflows/<name>.samples.json, else each Step's first example minus what another Step feeds it (and never a brief:
|
|
85
|
+
// a Block's own example is never a Step's brief).
|
|
86
|
+
async function samplesFor(path,type){
|
|
87
|
+
try{return JSON.parse(await readFile(path.replace(/\.json$/,'.samples.json'),'utf8'));}catch(error){if(error.code!=='ENOENT')throw new Error(`${path.slice(root.length+1).replace(/\.json$/,'.samples.json')}: ${error.message}`);}
|
|
88
|
+
const samples={};
|
|
89
|
+
for(const stage of type.stages){
|
|
90
|
+
const pkg=packageForStage(type,stage,BUILTIN_BLOCKS),input=pkg?.tests?.[0]?.input;if(!input)continue;
|
|
91
|
+
const fed=new Set((type.connections||[]).filter(edge=>edge.to.stage===stage.id).map(edge=>edge.to.port)),briefs=new Set(briefInputNames(pkg.manifest));
|
|
92
|
+
const own=Object.fromEntries(Object.entries(input).filter(([port])=>!fed.has(port)&&!briefs.has(port)));
|
|
93
|
+
if(Object.keys(own).length)samples[stage.id]=own;
|
|
94
|
+
}
|
|
95
|
+
return samples;
|
|
96
|
+
}
|
|
97
|
+
async function runWorkflow(key,inputs,onStep){
|
|
98
|
+
const path=workflowPath(key);if(!path)throw new Error('No such workflow in this workspace.');
|
|
99
|
+
const type=await loadProjectType(path),runner=typeModuleRunner({mock:true,readLocalFile:localFileReader(root),...(typeMocksFor(type)?{mocksFor:typeMocksFor(type)}:{})});
|
|
100
|
+
lastRunner=runner;
|
|
101
|
+
const result=await previewProjectType(type,{inputs:inputs&&typeof inputs==='object'?inputs:await samplesFor(path,type),run:runner.run,onResult:onStep,interactive:true});
|
|
102
|
+
const calls=runner.calls();
|
|
103
|
+
return {...result,...(calls.length?{standIns:calls}:{})};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
root,
|
|
108
|
+
session(key){const path=blockPath(key);if(!path)return null;if(!sessions.has(key))sessions.set(key,blockSession(path,picks));return sessions.get(key);},
|
|
109
|
+
folderOf:key=>join(root,key),
|
|
110
|
+
list,changed,workflow,runWorkflow,
|
|
111
|
+
build:names=>buildWorkspace(root,{only:names}),
|
|
112
|
+
media:url=>lastRunner?.file(url)||null,
|
|
113
|
+
async check(){const verdict=await runReadyGate({root,main});return {passed:verdict.passed,checked:verdict.checked,results:verdict.results.map(({package:name,status,ok,failures})=>({package:name,status,ok,failures}))};},
|
|
114
|
+
templates:()=>Object.entries(TEMPLATES).map(([name,template])=>({name,runtime:template.runtime,description:template.description})),
|
|
115
|
+
// The preview's New Block: the same `add` an agent runs.
|
|
116
|
+
async add({name,template}={}){
|
|
117
|
+
const folder=folderName(name),path=join(root,BLOCKS_DIR,folder);
|
|
118
|
+
if(template!==undefined&&!Object.hasOwn(TEMPLATES,template))throw new Error(`Unknown template ${template}.`);
|
|
119
|
+
const created=await main(['create',path,...(template?[template]:[])]);
|
|
120
|
+
await adoptWorkspaceBlock(path);
|
|
121
|
+
return {key:`${BLOCKS_DIR}/${folder}`,template:created.template};
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// `stillmade-block pack` at a workspace's top: every Block and workflow, each packed exactly as `pack` packs it alone,
|
|
2
|
+
// in one .stillmade-pack file (packages/block-sdk/stillmade-pack.js).
|
|
3
|
+
import {mkdtemp,readFile,rm,writeFile} from 'node:fs/promises';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import {join,resolve} from 'node:path';
|
|
6
|
+
import {createHash} from 'node:crypto';
|
|
7
|
+
import {createRequire} from 'node:module';
|
|
8
|
+
import {PACK_FORMAT,PACK_INDEX,PACK_LIMITS,PACK_SUFFIX,validatePackIndex} from '../block-sdk/stillmade-pack.js';
|
|
9
|
+
import {blockKind,buildWorkspace,findWorkspace,workspaceItems,unbundledImport,BLOCKS_DIR,WORKFLOWS_DIR} from './workspace.js';
|
|
10
|
+
|
|
11
|
+
const require=createRequire(import.meta.url);
|
|
12
|
+
function zipCodec(){try{return require('fflate');}catch{return createRequire(new URL('../../client/package.json',import.meta.url))('fflate');}}
|
|
13
|
+
const sha256=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
14
|
+
|
|
15
|
+
export async function packWorkspace(root,{main,output}={}){
|
|
16
|
+
const {config}=await findWorkspace(root);
|
|
17
|
+
const build=await buildWorkspace(root);
|
|
18
|
+
if(build.failed.length)throw Object.assign(new Error(`Build failed for ${build.failed.map(item=>item.block).join(', ')}`),{code:'BUILD',report:build});
|
|
19
|
+
const {blocks,workflows}=await workspaceItems(root);
|
|
20
|
+
if(!blocks.length)throw Object.assign(new Error('This workspace has no Blocks to pack yet.'),{code:'USAGE'});
|
|
21
|
+
const broken=[...blocks,...workflows].find(item=>item.problem);if(broken)throw new Error(broken.problem);
|
|
22
|
+
for(const item of blocks){const loose=await unbundledImport(item.path);if(loose)throw Object.assign(new Error(`${BLOCKS_DIR}/${item.name}: ${loose.file} imports ${loose.specifier}. Keep the code in src/main.js and run \`stillmade-block build\`.`),{code:'BUILD'});}
|
|
23
|
+
const scratch=await mkdtemp(join(tmpdir(),'stillmade-pack-')),files={},items=[];
|
|
24
|
+
try{
|
|
25
|
+
// Each item through the same pack a single Block or Type gets; a failing test stops the whole pack.
|
|
26
|
+
const one=async(item,folder,suffix)=>{
|
|
27
|
+
let result;
|
|
28
|
+
try{result=await main(['pack',item.path,join(scratch,`${folder}-${item.name}${suffix}`)]);}
|
|
29
|
+
catch(error){throw Object.assign(new Error(`${folder}/${item.name}: ${error.message}`),{code:error.code,report:error.report});}
|
|
30
|
+
return new Uint8Array(await readFile(result.file));
|
|
31
|
+
};
|
|
32
|
+
for(const item of blocks){
|
|
33
|
+
const suffix=await suffixFor(item),bytes=await one(item,BLOCKS_DIR,suffix),manifest=item.manifest;
|
|
34
|
+
const path=`${BLOCKS_DIR}/${manifest.id}-${manifest.version}${suffix}`;
|
|
35
|
+
files[path]=bytes;
|
|
36
|
+
items.push({kind:'block',blockKind:blockKind(manifest),id:manifest.id,version:manifest.version,name:manifest.name,...(manifest.description?{description:String(manifest.description).slice(0,300)}:{}),folder:item.name,path,sha256:sha256(bytes)});
|
|
37
|
+
}
|
|
38
|
+
for(const item of workflows){
|
|
39
|
+
const bytes=await one(item,WORKFLOWS_DIR,'.stillmade.json'),type=JSON.parse(new TextDecoder().decode(bytes));
|
|
40
|
+
const path=`${WORKFLOWS_DIR}/${type.id}-${type.version}.stillmade.json`;
|
|
41
|
+
const own=new Set(items.map(entry=>`${entry.id}@${entry.version}`));
|
|
42
|
+
files[path]=bytes;
|
|
43
|
+
items.push({kind:'workflow',id:type.id,version:type.version,name:type.name,...(type.description?{description:String(type.description).slice(0,300)}:{}),...(type.stages?.[0]?.blockId==='sm.when'?{automation:true}:{}),folder:item.name,path,sha256:sha256(bytes),
|
|
44
|
+
needs:[...new Set(type.stages.map(stage=>`${stage.blockId}@${stage.version}`))].filter(need=>own.has(need))});
|
|
45
|
+
}
|
|
46
|
+
}finally{await rm(scratch,{recursive:true,force:true});}
|
|
47
|
+
const index=validatePackIndex({format:PACK_FORMAT,name:config.name,version:config.version,...(config.description?{description:config.description}:{}),items});
|
|
48
|
+
const archive=zipCodec().zipSync({[PACK_INDEX]:new TextEncoder().encode(`${JSON.stringify(index,null,2)}\n`),...files},{level:6});
|
|
49
|
+
if(archive.byteLength>PACK_LIMITS.bytes)throw Object.assign(new Error(`The pack is ${Math.round(archive.byteLength/1048576)} MB; StillMade's Import takes up to ${PACK_LIMITS.bytes/1048576} MB. Pack fewer Blocks, or move large files out of module/.`),{code:'STILLMADE_PACK'});
|
|
50
|
+
const slug=config.name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'')||'blocks';
|
|
51
|
+
const file=output||resolve(root,`${slug}-${config.version}${PACK_SUFFIX}`);
|
|
52
|
+
if(!file.endsWith(PACK_SUFFIX))throw Object.assign(new Error(`A workspace pack ends in ${PACK_SUFFIX}.`),{code:'USAGE'});
|
|
53
|
+
await writeFile(file,archive);
|
|
54
|
+
return {pack:file,bytes:archive.byteLength,items:index.items.map(({kind,blockKind:kindOfBlock,id,version,name,needs})=>({kind:kindOfBlock||kind,id,version,name,...(needs?{needs}:{})})),...(build.built.length?{built:build.built}:{})};
|
|
55
|
+
}
|
|
56
|
+
// The file name pack gives a Block: a portable archive for a portable folder, a module bundle for a Block with digested
|
|
57
|
+
// files (module/ or src/view-files/), else plain package JSON.
|
|
58
|
+
async function suffixFor(item){
|
|
59
|
+
const {readPackage}=await import('./cli.js'),{hasDigestFiles,MODULE_BUNDLE_SUFFIX}=await import('../block-sdk/module-bundle.js');
|
|
60
|
+
const pkg=await readPackage(item.path);
|
|
61
|
+
return pkg.portable?'.stillmade-block':hasDigestFiles(pkg)?MODULE_BUNDLE_SUFFIX:'.stillmade.json';
|
|
62
|
+
}
|