@offsida/cli 0.2.1 → 0.4.0

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/dist/bundle.d.ts CHANGED
@@ -1,6 +1,19 @@
1
+ import type { ArtifactFile } from './resolve-package.js';
2
+ export declare function ensureInit(): Promise<void>;
1
3
  export declare function bundlePackage(dir: string, opts?: {
2
4
  alias?: Record<string, string>;
3
5
  }): Promise<string>;
4
6
  export declare function bundleEntry(entryFile: string, opts?: {
5
7
  alias?: Record<string, string>;
6
8
  }): Promise<string>;
9
+ /**
10
+ * The generated entry for `<name>.action.ts`: its default export, as the one action of a definePackage
11
+ * named after the file. definePackage is what turns Zod schemas into JSON Schema; the guard turns a
12
+ * missing or wrong default export into the publish probe's failure reason instead of a run-time miss.
13
+ */
14
+ export declare function actionWrapper(dir: string, artifact: ArtifactFile): string;
15
+ export type BundledArtifact = {
16
+ code: string;
17
+ inputs: string[];
18
+ };
19
+ export declare function bundleArtifact(dir: string, artifact: ArtifactFile): Promise<BundledArtifact>;
package/dist/bundle.js CHANGED
@@ -1,41 +1,71 @@
1
1
  import * as esbuild from 'esbuild-wasm';
2
- import { join } from 'node:path';
2
+ import { join, relative, resolve, sep } from 'node:path';
3
3
  let initialized = false;
4
- async function ensureInit() {
4
+ export async function ensureInit() {
5
5
  if (initialized)
6
6
  return;
7
7
  await esbuild.initialize({});
8
8
  initialized = true;
9
9
  }
10
+ const OPTIONS = {
11
+ bundle: true,
12
+ format: 'esm',
13
+ platform: 'neutral',
14
+ mainFields: ['module', 'main'],
15
+ conditions: ['workerd', 'worker', 'browser', 'default'],
16
+ external: ['cloudflare:workers', 'cloudflare:test', 'node:*', 'fs', 'fs/promises', 'path'],
17
+ target: 'es2022',
18
+ write: false,
19
+ legalComments: 'none',
20
+ // Halves an SDK package (most of it is zod) against the platform's 1 MiB cap (push.ts CODE_SIZE_LIMIT).
21
+ minify: true,
22
+ };
23
+ function outputText(result) {
24
+ const out = result.outputFiles?.[0]?.text;
25
+ if (!out)
26
+ throw new Error('esbuild produced no output');
27
+ return out;
28
+ }
10
29
  // Bundle <dir>/index.ts (and its local imports) into a single ESM module string,
11
- // preserving the package's `export default {...}`. Used by `offsida build` and by
12
- // the platform conformance test. `opts.alias` lets callers resolve bare import
30
+ // preserving the package's `export default {...}`. Used by the platform conformance
31
+ // test and `pushPackage`. `opts.alias` lets callers resolve bare import
13
32
  // prefixes (e.g. the `@core` workspace alias) — esbuild rewrites subpaths, so
14
33
  // `{ '@core': '/abs/lib/core/src' }` maps `@core/services/x` → `/abs/lib/core/src/services/x`.
15
34
  export async function bundlePackage(dir, opts) {
16
35
  return bundleEntry(join(dir, 'index.ts'), opts);
17
36
  }
18
- // Bundle one entry file (and its local imports). Used by the package-rooted `push` / `build`, whose
19
- // entry comes from package.json or the src/index.ts convention (resolve-package.ts).
37
+ // Bundle one entry file (and its local imports).
20
38
  export async function bundleEntry(entryFile, opts) {
21
39
  await ensureInit();
22
- const result = await esbuild.build({
23
- entryPoints: [entryFile],
24
- bundle: true,
25
- format: 'esm',
26
- platform: 'neutral',
27
- mainFields: ['module', 'main'],
28
- conditions: ['workerd', 'worker', 'browser', 'default'],
29
- external: ['cloudflare:workers', 'cloudflare:test', 'node:*', 'fs', 'fs/promises', 'path'],
30
- alias: opts?.alias,
31
- target: 'es2022',
32
- write: false,
33
- legalComments: 'none',
34
- // Halves an SDK package (most of it is zod) against the platform's 1 MiB cap (push.ts CODE_SIZE_LIMIT).
35
- minify: true,
36
- });
37
- const out = result.outputFiles?.[0]?.text;
38
- if (!out)
39
- throw new Error('esbuild produced no output');
40
- return out;
40
+ return outputText(await esbuild.build({ ...OPTIONS, entryPoints: [entryFile], alias: opts?.alias }));
41
+ }
42
+ /**
43
+ * The generated entry for `<name>.action.ts`: its default export, as the one action of a definePackage
44
+ * named after the file. definePackage is what turns Zod schemas into JSON Schema; the guard turns a
45
+ * missing or wrong default export into the publish probe's failure reason instead of a run-time miss.
46
+ */
47
+ export function actionWrapper(dir, artifact) {
48
+ const rel = relative(dir, artifact.file).split(sep).join('/');
49
+ return [
50
+ `import { definePackage } from '@offsida/sdk';`,
51
+ `import action from ${JSON.stringify(`./${rel}`)};`,
52
+ `if (!action || typeof action.run !== 'function') {`,
53
+ `\tthrow new Error(${JSON.stringify(`${rel} must default-export defineAction({ … run … })`)});`,
54
+ `}`,
55
+ `export default definePackage({ actions: { ${JSON.stringify(artifact.name)}: action } });`,
56
+ ``,
57
+ ].join('\n');
58
+ }
59
+ // Bundle one artifact file. `inputs` lists every source file in the bundle (absolute), so a caller can
60
+ // see what a package file pulls in.
61
+ export async function bundleArtifact(dir, artifact) {
62
+ await ensureInit();
63
+ const entry = artifact.kind === 'action'
64
+ ? { stdin: { contents: actionWrapper(dir, artifact), resolveDir: dir, sourcefile: `<offsida:${artifact.name}>`, loader: 'ts' } }
65
+ : { entryPoints: [artifact.file] };
66
+ const result = await esbuild.build({ ...OPTIONS, ...entry, absWorkingDir: dir, metafile: true });
67
+ const inputs = Object.keys(result.metafile.inputs)
68
+ .filter((p) => !p.startsWith('<'))
69
+ .map((p) => resolve(dir, p));
70
+ return { code: outputText(result), inputs };
41
71
  }
package/dist/cli.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { type PushDirectoryDeps } from './push-directory.js';
3
- export declare const USAGE = "usage:\n offsida push [dir] [--publish] [--url <u>] [--org <id>] [--allow-remote]\n offsida build [dir] [--out <file>]\n\ndir defaults to the current directory and must contain a package.json.\n\npush uploads the package's code (its \"exports\"/\"main\", else src/index.ts or index.ts)\nas artifact <package name without scope> (override: \"offsida\": { \"name\": \"\u2026\" }),\nthen every *.workflow.json as that workflow's draft, replacing edits made in the\nOffsida editor. Subfolders with their own package.json are separate packages and\nare skipped. A file's optional \"id\" pins one workflow in one org; without it the\nworkflow is matched by name or created. --publish also publishes each workflow;\n\"latest\" steps lock to the code just pushed.\n\nA first publish goes live with no capability confirmation (offsida.web#14). A later\npublish that adds access is left for confirming in Offsida (exit 2). Every push mints\na new code revision, so --publish republishes \"latest\" workflows (offsida.web#15).\n\nAuth (environment only): OFFSIDA_TOKEN, or OFFSIDA_EMAIL + OFFSIDA_CODE.\nScopes: code:publish; workflows:list, :get, :create, :update; workflows:publish with --publish.\nAlso: OFFSIDA_URL (default http://localhost:3808), OFFSIDA_ORG, OFFSIDA_ALLOW_REMOTE=1.\n\nLimits: name matching reads a list that lags a few seconds, so two quick pushes of a\nnew workflow can create two. Same-named workflows in different packages overwrite\neach other without \"id\". Deleting a *.workflow.json leaves its workflow live.\n\nBundles are minified and must stay under the platform's 1 MiB cap. A rate-limited\nrequest (429) waits out its Retry-After, up to a minute, and is retried.\n\nExit codes: 0 done, 1 something failed, 2 something needs confirming in Offsida.";
3
+ export declare const USAGE = "usage:\n offsida push [dir] [--publish] [--url <u>] [--org <id>] [--allow-remote]\n offsida build [dir] [--out-dir <dir>]\n\ndir defaults to the current directory and must contain a package.json.\n\nEvery file below dir is one artifact, named after the file:\n <name>.action.ts default-exports defineAction(\u2026); uploaded as a package holding that\n one action, also called <name>. Names are kebab-case (a-z, 0-9, -).\n <name>.package.ts default-exports definePackage(\u2026) \u2014 for connections, triggers, config,\n or several actions sharing one revision. Letters, digits, . _ -.\nNames are per org: two packages pushing the same name overwrite each other's artifact.\nA package file may not import an action file (that action would be published twice).\n\npush uploads every package file, then every action file (so an action requiring a\npackage's connection gets the one just pushed), then every *.workflow.json \u2014 and every\n*.workflow.ts, compiled \u2014 as that workflow's draft, replacing edits made in the Offsida\neditor. A failed upload skips\nthe workflows. Subfolders with their own package.json are separate packages and are\nskipped, as are node_modules, dist and dot-folders. A workflow file's optional \"id\"\npins one workflow in one org; without it the workflow is matched by name or created.\n--publish also publishes each workflow; \"latest\" steps lock to the code just pushed.\n\nA *.workflow.ts default-exports defineWorkflow(\u2026) from @offsida/sdk/workflow. It is\nbundled and run in Node at push (author code, like any build script); an import of a\n*.action.ts becomes that action's name, so the action's own code never runs.\n\nbuild bundles every artifact and compiles every *.workflow.ts without uploading: with\n--out-dir it writes <name>.js per artifact and <file>.json per typed workflow; without,\nit prints each artifact's size against the cap and each typed workflow's name.\n\nA first publish goes live with no capability confirmation (offsida.web#14). A later\npublish that adds access is left for confirming in Offsida (exit 2). Every push mints\na new revision of every artifact, so --publish republishes \"latest\" workflows\n(offsida.web#15).\n\nAuth (environment only): OFFSIDA_TOKEN, or OFFSIDA_EMAIL + OFFSIDA_CODE.\nScopes: code:publish; workflows:list, :get, :create, :update; workflows:publish with --publish.\nAlso: OFFSIDA_URL (default http://localhost:3808), OFFSIDA_ORG, OFFSIDA_ALLOW_REMOTE=1.\n\nLimits: name matching reads a list that lags a few seconds, so two quick pushes of a\nnew workflow can create two. Same-named workflows in different packages overwrite\neach other without \"id\". Deleting a *.workflow.json leaves its workflow live.\n\nBundles are minified and must stay under the platform's 1 MiB cap. A rate-limited\nrequest (429) waits out its Retry-After, up to a minute, and is retried.\n\nExit codes: 0 done, 1 something failed, 2 something needs confirming in Offsida.";
4
4
  type RunDeps = PushDirectoryDeps & {
5
5
  out?: (s: string) => void;
6
6
  err?: (s: string) => void;
package/dist/cli.js CHANGED
@@ -1,27 +1,48 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from 'node:fs';
3
- import { writeFile } from 'node:fs/promises';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ import { basename, join, relative } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
- import { bundleEntry } from './bundle.js';
6
+ import { compileWorkflowFiles } from './compile-workflow.js';
7
+ import { prepareArtifacts } from './prepare.js';
8
+ import { CODE_SIZE_LIMIT } from './push.js';
6
9
  import { pushDirectory } from './push-directory.js';
7
10
  import { resolvePackage } from './resolve-package.js';
8
11
  export const USAGE = `usage:
9
12
  offsida push [dir] [--publish] [--url <u>] [--org <id>] [--allow-remote]
10
- offsida build [dir] [--out <file>]
13
+ offsida build [dir] [--out-dir <dir>]
11
14
 
12
15
  dir defaults to the current directory and must contain a package.json.
13
16
 
14
- push uploads the package's code (its "exports"/"main", else src/index.ts or index.ts)
15
- as artifact <package name without scope> (override: "offsida": { "name": "…" }),
16
- then every *.workflow.json as that workflow's draft, replacing edits made in the
17
- Offsida editor. Subfolders with their own package.json are separate packages and
18
- are skipped. A file's optional "id" pins one workflow in one org; without it the
19
- workflow is matched by name or created. --publish also publishes each workflow;
20
- "latest" steps lock to the code just pushed.
17
+ Every file below dir is one artifact, named after the file:
18
+ <name>.action.ts default-exports defineAction(…); uploaded as a package holding that
19
+ one action, also called <name>. Names are kebab-case (a-z, 0-9, -).
20
+ <name>.package.ts default-exports definePackage(…) — for connections, triggers, config,
21
+ or several actions sharing one revision. Letters, digits, . _ -.
22
+ Names are per org: two packages pushing the same name overwrite each other's artifact.
23
+ A package file may not import an action file (that action would be published twice).
24
+
25
+ push uploads every package file, then every action file (so an action requiring a
26
+ package's connection gets the one just pushed), then every *.workflow.json — and every
27
+ *.workflow.ts, compiled — as that workflow's draft, replacing edits made in the Offsida
28
+ editor. A failed upload skips
29
+ the workflows. Subfolders with their own package.json are separate packages and are
30
+ skipped, as are node_modules, dist and dot-folders. A workflow file's optional "id"
31
+ pins one workflow in one org; without it the workflow is matched by name or created.
32
+ --publish also publishes each workflow; "latest" steps lock to the code just pushed.
33
+
34
+ A *.workflow.ts default-exports defineWorkflow(…) from @offsida/sdk/workflow. It is
35
+ bundled and run in Node at push (author code, like any build script); an import of a
36
+ *.action.ts becomes that action's name, so the action's own code never runs.
37
+
38
+ build bundles every artifact and compiles every *.workflow.ts without uploading: with
39
+ --out-dir it writes <name>.js per artifact and <file>.json per typed workflow; without,
40
+ it prints each artifact's size against the cap and each typed workflow's name.
21
41
 
22
42
  A first publish goes live with no capability confirmation (offsida.web#14). A later
23
43
  publish that adds access is left for confirming in Offsida (exit 2). Every push mints
24
- a new code revision, so --publish republishes "latest" workflows (offsida.web#15).
44
+ a new revision of every artifact, so --publish republishes "latest" workflows
45
+ (offsida.web#15).
25
46
 
26
47
  Auth (environment only): OFFSIDA_TOKEN, or OFFSIDA_EMAIL + OFFSIDA_CODE.
27
48
  Scopes: code:publish; workflows:list, :get, :create, :update; workflows:publish with --publish.
@@ -39,14 +60,15 @@ Exit codes: 0 done, 1 something failed, 2 something needs confirming in Offsida.
39
60
  // particular `--token`/`--code`, so a secret never has to sit in argv or shell history.
40
61
  const FLAGS = {
41
62
  push: { '--publish': false, '--url': true, '--org': true, '--allow-remote': false },
42
- build: { '--out': true },
63
+ build: { '--out-dir': true },
43
64
  };
44
65
  // Flags the package-rooted `push` dropped, and where each one went.
45
66
  const REMOVED = {
46
67
  '--token': 'set OFFSIDA_TOKEN (or OFFSIDA_EMAIL + OFFSIDA_CODE) in the environment',
47
68
  '--email': 'set OFFSIDA_EMAIL and OFFSIDA_CODE in the environment',
48
69
  '--code': 'set OFFSIDA_EMAIL and OFFSIDA_CODE in the environment',
49
- '--name': 'set "offsida": { "name": "…" } in package.json',
70
+ '--name': 'name the file <name>.action.ts or <name>.package.ts',
71
+ '--out': 'use --out-dir <dir>, which writes one <name>.js per artifact',
50
72
  };
51
73
  class UsageError extends Error {
52
74
  }
@@ -92,16 +114,40 @@ export async function run(argv, env, deps = {}) {
92
114
  const { dir, flags } = parse(command, args);
93
115
  if (command === 'build') {
94
116
  const pkg = await resolvePackage(dir);
95
- if (!pkg.entry)
96
- throw new Error(`${dir}: no code entry to build`);
97
- const code = await bundleEntry(pkg.entry);
98
- const file = flags.get('--out');
99
- if (typeof file === 'string') {
100
- await writeFile(file, code);
101
- out(`wrote ${file}`);
117
+ for (const w of pkg.warnings)
118
+ err(`warning: ${w}`);
119
+ if (pkg.artifacts.length === 0 && pkg.workflowTsFiles.length === 0) {
120
+ throw new Error(`${dir}: no *.action.ts, *.package.ts or *.workflow.ts files to build`);
121
+ }
122
+ const artifacts = await prepareArtifacts(pkg);
123
+ const workflows = await compileWorkflowFiles(pkg);
124
+ const outDir = flags.get('--out-dir');
125
+ if (typeof outDir === 'string') {
126
+ await mkdir(outDir, { recursive: true });
127
+ for (const a of artifacts) {
128
+ const file = join(outDir, `${a.name}.js`);
129
+ await writeFile(file, a.code);
130
+ out(`wrote ${file}`);
131
+ }
132
+ for (const w of workflows) {
133
+ const file = join(outDir, `${basename(w.path, '.ts')}.json`);
134
+ const { path: _path, ...json } = w;
135
+ await writeFile(file, `${JSON.stringify(json, null, 2)}\n`);
136
+ out(`wrote ${file}`);
137
+ }
138
+ }
139
+ else {
140
+ for (const w of workflows)
141
+ out(`workflow "${w.name}" ${relative(pkg.dir, w.path)}`);
142
+ const rows = artifacts.map((a) => [a.name, relative(pkg.dir, a.file), a.code.length]);
143
+ if (rows.length === 0)
144
+ return 0;
145
+ const w0 = Math.max(...rows.map((r) => r[0].length));
146
+ const w1 = Math.max(...rows.map((r) => r[1].length));
147
+ for (const [name, file, size] of rows) {
148
+ out(`${name.padEnd(w0)} ${file.padEnd(w1)} ${size} chars (${Math.round((size / CODE_SIZE_LIMIT) * 100)}% of 1 MiB)`);
149
+ }
102
150
  }
103
- else
104
- out(code);
105
151
  return 0;
106
152
  }
107
153
  const url = flags.get('--url');
@@ -0,0 +1,11 @@
1
+ import { type ResolvedPackage } from './resolve-package.js';
2
+ import { type WorkflowFile } from './workflow-file.js';
3
+ /**
4
+ * Compile one `*.workflow.ts`: bundle it for Node (packages stay external, so `@offsida/sdk` resolves from
5
+ * the package's own install), import it, and call its default export's `compile()`. The bundle is written
6
+ * under the package's `node_modules/.cache/offsida/` so bare imports resolve, and removed afterwards.
7
+ * Running the file is running author code — the same trust as any build script.
8
+ */
9
+ export declare function compileWorkflowFile(pkg: ResolvedPackage, file: string): Promise<WorkflowFile>;
10
+ /** Compile every `*.workflow.ts` in the package, reporting every failure in one error. */
11
+ export declare function compileWorkflowFiles(pkg: ResolvedPackage): Promise<WorkflowFile[]>;
@@ -0,0 +1,122 @@
1
+ import * as esbuild from 'esbuild-wasm';
2
+ import { realpathSync } from 'node:fs';
3
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { basename, join, relative } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { ensureInit } from './bundle.js';
7
+ import { ACTION_NAME } from './resolve-package.js';
8
+ import { parseWorkflowFile } from './workflow-file.js';
9
+ const ACTION_SUFFIX = '.action.ts';
10
+ /**
11
+ * The plugin that makes a typed workflow cheap to compile: an import of a `*.action.ts` becomes a marker
12
+ * carrying the action's name (its file name), which `step.action` reads. TypeScript still checks the
13
+ * workflow against the real action; the compiled workflow never loads the action's code or dependencies.
14
+ */
15
+ function actionMarkers(pkg) {
16
+ // esbuild reports real paths, so compare real paths (a temp dir or a checkout may sit behind a symlink).
17
+ const real = (p) => realpathSync(p);
18
+ const root = real(pkg.dir);
19
+ const actions = new Set(pkg.artifacts.filter((a) => a.kind === 'action').map((a) => real(a.file)));
20
+ return {
21
+ name: 'offsida-action-markers',
22
+ setup(build) {
23
+ build.onResolve({ filter: /\.action(\.ts)?$/ }, async (args) => {
24
+ if (args.pluginData === 'offsida-resolving')
25
+ return undefined;
26
+ const r = await build.resolve(args.path, { resolveDir: args.resolveDir, kind: args.kind, pluginData: 'offsida-resolving' });
27
+ if (r.errors.length > 0 || !r.path.endsWith(ACTION_SUFFIX))
28
+ return undefined;
29
+ return { path: r.path, namespace: 'offsida-action' };
30
+ });
31
+ build.onLoad({ filter: /.*/, namespace: 'offsida-action' }, (args) => {
32
+ const rel = relative(root, args.path);
33
+ const name = basename(args.path).slice(0, -ACTION_SUFFIX.length);
34
+ if (!actions.has(args.path)) {
35
+ return { errors: [{ text: `${rel} is not an action of this package — a workflow can only call actions pushed with it` }] };
36
+ }
37
+ if (!ACTION_NAME.test(name))
38
+ return { errors: [{ text: `${rel}: an action file name must be kebab-case` }] };
39
+ return { contents: `export default { [Symbol.for('offsida.action')]: { name: ${JSON.stringify(name)} } };`, loader: 'js' };
40
+ });
41
+ },
42
+ };
43
+ }
44
+ function buildMessage(e) {
45
+ const errors = e.errors;
46
+ if (!errors?.length)
47
+ return e.message;
48
+ return errors.map((x) => (x.location ? `${x.text} (${x.location.file}:${x.location.line})` : x.text)).join('; ');
49
+ }
50
+ /**
51
+ * Compile one `*.workflow.ts`: bundle it for Node (packages stay external, so `@offsida/sdk` resolves from
52
+ * the package's own install), import it, and call its default export's `compile()`. The bundle is written
53
+ * under the package's `node_modules/.cache/offsida/` so bare imports resolve, and removed afterwards.
54
+ * Running the file is running author code — the same trust as any build script.
55
+ */
56
+ export async function compileWorkflowFile(pkg, file) {
57
+ const rel = relative(pkg.dir, file);
58
+ await ensureInit();
59
+ let code;
60
+ try {
61
+ const result = await esbuild.build({
62
+ entryPoints: [file],
63
+ bundle: true,
64
+ format: 'esm',
65
+ platform: 'node',
66
+ target: 'node20',
67
+ packages: 'external',
68
+ absWorkingDir: pkg.dir,
69
+ write: false,
70
+ logLevel: 'silent',
71
+ plugins: [actionMarkers(pkg)],
72
+ });
73
+ code = result.outputFiles[0].text;
74
+ }
75
+ catch (e) {
76
+ throw new Error(`${rel}: ${buildMessage(e)}`);
77
+ }
78
+ const cacheDir = join(pkg.dir, 'node_modules', '.cache', 'offsida');
79
+ await mkdir(cacheDir, { recursive: true });
80
+ const out = join(cacheDir, `${basename(file, '.ts')}-${process.pid}-${Date.now()}.mjs`);
81
+ await writeFile(out, code);
82
+ try {
83
+ let mod;
84
+ try {
85
+ mod = (await import(pathToFileURL(out).href));
86
+ }
87
+ catch (e) {
88
+ throw new Error(`${rel}: ${e.message}`);
89
+ }
90
+ const workflow = mod.default;
91
+ if (!workflow || typeof workflow.compile !== 'function') {
92
+ throw new Error(`${rel}: must default-export defineWorkflow({ … }) from @offsida/sdk/workflow`);
93
+ }
94
+ let compiled;
95
+ try {
96
+ compiled = workflow.compile();
97
+ }
98
+ catch (e) {
99
+ throw new Error(`${rel}: ${e.message}`);
100
+ }
101
+ return parseWorkflowFile(file, compiled);
102
+ }
103
+ finally {
104
+ await rm(out, { force: true });
105
+ }
106
+ }
107
+ /** Compile every `*.workflow.ts` in the package, reporting every failure in one error. */
108
+ export async function compileWorkflowFiles(pkg) {
109
+ const out = [];
110
+ const problems = [];
111
+ for (const file of pkg.workflowTsFiles) {
112
+ try {
113
+ out.push(await compileWorkflowFile(pkg, file));
114
+ }
115
+ catch (e) {
116
+ problems.push(e.message);
117
+ }
118
+ }
119
+ if (problems.length > 0)
120
+ throw new Error(problems.join('\n'));
121
+ return out;
122
+ }
@@ -0,0 +1,11 @@
1
+ import type { ArtifactFile, ResolvedPackage } from './resolve-package.js';
2
+ export type PreparedArtifact = ArtifactFile & {
3
+ code: string;
4
+ };
5
+ /**
6
+ * Bundle every artifact and run every check that needs a bundle, before anything is uploaded: the SDK
7
+ * an action file's wrapper imports (reported once, however many action files), a build that fails, a bundle over the platform cap, and a package
8
+ * file that pulls in an action file (which would publish that action twice). Every problem is reported
9
+ * in one error. Returned in the package's artifact order.
10
+ */
11
+ export declare function prepareArtifacts(pkg: ResolvedPackage): Promise<PreparedArtifact[]>;
@@ -0,0 +1,65 @@
1
+ import { basename, relative } from 'node:path';
2
+ import { bundleArtifact } from './bundle.js';
3
+ import { CODE_SIZE_LIMIT } from './push.js';
4
+ const ACTION_SUFFIX = '.action.ts';
5
+ const WRAPPER = '<offsida:';
6
+ // esbuild reports each error with a location relative to the package; the generated wrapper's is a
7
+ // pseudo-path, so it is named instead.
8
+ function buildMessage(e) {
9
+ const errors = e.errors;
10
+ if (!errors?.length)
11
+ return e.message;
12
+ return errors
13
+ .map((x) => {
14
+ if (!x.location)
15
+ return x.text;
16
+ const where = x.location.file.includes(WRAPPER) ? 'generated wrapper' : `${x.location.file}:${x.location.line}`;
17
+ return `${x.text} (${where})`;
18
+ })
19
+ .join('; ');
20
+ }
21
+ // The wrapper's own `import … from '@offsida/sdk'` failed: resolution is left to esbuild, the same
22
+ // resolver the bundle uses (Node's would also search NODE_PATH, which pnpm sets for every script).
23
+ const sdkMissing = (e) => !!e.errors?.some((x) => x.text === 'Could not resolve "@offsida/sdk"' && x.location?.file.includes(WRAPPER));
24
+ /**
25
+ * Bundle every artifact and run every check that needs a bundle, before anything is uploaded: the SDK
26
+ * an action file's wrapper imports (reported once, however many action files), a build that fails, a bundle over the platform cap, and a package
27
+ * file that pulls in an action file (which would publish that action twice). Every problem is reported
28
+ * in one error. Returned in the package's artifact order.
29
+ */
30
+ export async function prepareArtifacts(pkg) {
31
+ const problems = [];
32
+ let noSdk = false;
33
+ const prepared = [];
34
+ for (const artifact of pkg.artifacts) {
35
+ const rel = relative(pkg.dir, artifact.file);
36
+ let bundled;
37
+ try {
38
+ bundled = await bundleArtifact(pkg.dir, artifact);
39
+ }
40
+ catch (e) {
41
+ if (artifact.kind === 'action' && sdkMissing(e)) {
42
+ noSdk = true;
43
+ continue;
44
+ }
45
+ problems.push(`${rel}: ${buildMessage(e)}`);
46
+ continue;
47
+ }
48
+ if (artifact.kind === 'package') {
49
+ for (const input of bundled.inputs.filter((p) => p.endsWith(ACTION_SUFFIX))) {
50
+ const shared = basename(input).slice(0, -ACTION_SUFFIX.length);
51
+ problems.push(`${rel} imports ${relative(pkg.dir, input)}, which is already its own artifact — move the shared code into a plain module (e.g. ${shared}.ts) that both import`);
52
+ }
53
+ }
54
+ if (bundled.code.length > CODE_SIZE_LIMIT) {
55
+ problems.push(`${rel} bundles to ${bundled.code.length} characters; the platform cap is ${CODE_SIZE_LIMIT} (1 MiB) — trim dependencies`);
56
+ }
57
+ prepared.push({ ...artifact, code: bundled.code });
58
+ }
59
+ if (noSdk) {
60
+ problems.unshift(`@offsida/sdk is not installed in ${pkg.dir} — action files are wrapped in its definePackage; add it as a dependency`);
61
+ }
62
+ if (problems.length > 0)
63
+ throw new Error(problems.join('\n'));
64
+ return prepared;
65
+ }
@@ -10,8 +10,8 @@ export type PushDirectoryDeps = {
10
10
  idempotencyKey?: () => string;
11
11
  };
12
12
  /**
13
- * `offsida push [dir]`: the package's code, then each *.workflow.json as its workflow's draft (and,
14
- * with `publish`, published — `latest` steps lock to the code just pushed). Throws only before anything
13
+ * `offsida push [dir]`: each *.package.ts / *.action.ts as its own artifact, then each *.workflow.json
14
+ * and compiled *.workflow.ts as its workflow's draft (and, with `publish`, published — `latest` steps lock to the code just pushed). Throws only before anything
15
15
  * is pushed (pre-flight, target, auth). After that every problem is reported and the result is the worst
16
16
  * outcome: 1 failed, 2 needs confirming in Offsida, 0 done.
17
17
  */
@@ -1,6 +1,7 @@
1
1
  import { relative } from 'node:path';
2
2
  import { connect } from './api.js';
3
- import { bundleEntry } from './bundle.js';
3
+ import { compileWorkflowFiles } from './compile-workflow.js';
4
+ import { prepareArtifacts } from './prepare.js';
4
5
  import { pushCode } from './push.js';
5
6
  import { resolvePackage } from './resolve-package.js';
6
7
  import { assertNoDuplicates, readWorkflowFile } from './workflow-file.js';
@@ -13,8 +14,8 @@ const SUMMARY = [
13
14
  ['failed', 'failed'],
14
15
  ];
15
16
  /**
16
- * `offsida push [dir]`: the package's code, then each *.workflow.json as its workflow's draft (and,
17
- * with `publish`, published — `latest` steps lock to the code just pushed). Throws only before anything
17
+ * `offsida push [dir]`: each *.package.ts / *.action.ts as its own artifact, then each *.workflow.json
18
+ * and compiled *.workflow.ts as its workflow's draft (and, with `publish`, published — `latest` steps lock to the code just pushed). Throws only before anything
18
19
  * is pushed (pre-flight, target, auth). After that every problem is reported and the result is the worst
19
20
  * outcome: 1 failed, 2 needs confirming in Offsida, 0 done.
20
21
  */
@@ -27,28 +28,38 @@ export async function pushDirectory(opts, deps = {}) {
27
28
  };
28
29
  // Pre-flight: everything that can be checked locally, before any request.
29
30
  const pkg = await resolvePackage(opts.dir);
31
+ for (const w of pkg.warnings)
32
+ log(`warning: ${w}`);
30
33
  const files = [];
31
34
  for (const path of pkg.workflowFiles)
32
35
  files.push(await readWorkflowFile(path));
36
+ files.push(...(await compileWorkflowFiles(pkg)));
33
37
  assertNoDuplicates(files);
34
38
  for (const f of files)
35
39
  if (f.id !== undefined)
36
40
  workflowDeps.pinned.add(f.id);
37
- if (!pkg.entry && files.length === 0)
38
- throw new Error(`${opts.dir}: nothing to push — no code entry and no *.workflow.json files`);
41
+ if (pkg.artifacts.length === 0 && files.length === 0) {
42
+ throw new Error(`${opts.dir}: nothing to push — no *.action.ts, *.package.ts, *.workflow.json or *.workflow.ts files`);
43
+ }
44
+ const artifacts = await prepareArtifacts(pkg);
39
45
  const api = await connect(opts, deps.fetch, workflowDeps.sleep);
40
- // Code first, so `latest` resolves to it. A failed build stops here: publishing workflows now would
41
- // lock them to the previous revision.
42
- if (pkg.entry) {
46
+ // Code first, so `latest` resolves to it; packages before actions, so an action requiring a package's
47
+ // connection resolves to the one just pushed. The artifacts are independent uploads, so one failing
48
+ // doesn't stop the rest — but it stops the workflows: publishing them now would lock their steps to
49
+ // that artifact's previous revision.
50
+ let codeFailed = false;
51
+ for (const a of artifacts) {
43
52
  try {
44
- const { revision } = await pushCode(api, pkg.artifactName, await bundleEntry(pkg.entry));
45
- log(`pushed code ${pkg.artifactName}@${revision}`);
53
+ const { revision } = await pushCode(api, a.name, a.code);
54
+ log(`pushed code ${a.name}@${revision}`);
46
55
  }
47
56
  catch (e) {
48
- log(`error: ${e.message}`);
49
- return 1;
57
+ log(`error: ${a.name}: ${e.message}`);
58
+ codeFailed = true;
50
59
  }
51
60
  }
61
+ if (codeFailed)
62
+ return 1;
52
63
  if (files.length === 0)
53
64
  return 0;
54
65
  // One list per run, and only when some file is matched by name.
@@ -1,12 +1,22 @@
1
+ export type ArtifactKind = 'action' | 'package';
2
+ /** One artifact file: `<name>.action.ts` (one defineAction) or `<name>.package.ts` (a definePackage). */
3
+ export type ArtifactFile = {
4
+ name: string;
5
+ kind: ArtifactKind;
6
+ file: string;
7
+ };
1
8
  export type ResolvedPackage = {
2
9
  dir: string;
3
- artifactName: string;
4
- entry: string | null;
10
+ artifacts: ArtifactFile[];
5
11
  workflowFiles: string[];
12
+ workflowTsFiles: string[];
13
+ warnings: string[];
6
14
  };
15
+ export declare const ACTION_NAME: RegExp;
7
16
  /**
8
- * A package directory → what `offsida push` uploads. Artifact name: `offsida.name`, else the npm name
9
- * without its scope. Code entry: exports["."] → main → src/index.ts → index.ts (first that exists;
10
- * null = no code). Workflows: every *.workflow.json below, skipping node_modules, dist, dot-dirs and nested packages.
17
+ * A package directory → what `offsida push` uploads. Artifacts: every `<name>.action.ts` and
18
+ * `<name>.package.ts` below, each named after its file, packages first (an action may require a
19
+ * package's connection) then by name. Workflows: every *.workflow.json and *.workflow.ts below. Both skip node_modules,
20
+ * dist, dot-dirs and nested packages. Every naming problem is reported in one error.
11
21
  */
12
22
  export declare function resolvePackage(dirArg: string): Promise<ResolvedPackage>;
@@ -1,7 +1,14 @@
1
1
  import { readFile, readdir, stat } from 'node:fs/promises';
2
- import { join, resolve } from 'node:path';
2
+ import { join, relative, resolve } from 'node:path';
3
3
  // The platform's artifact name rule (lib/contracts/src/schemas/code.ts publishBody).
4
4
  const ARTIFACT_NAME = /^[a-zA-Z0-9._-]{1,64}$/;
5
+ // An action file's name is also its action's name, which the manifest schema holds to kebab-case
6
+ // (lib/contracts/src/schemas/connections.ts actionDefinitionSchema).
7
+ export const ACTION_NAME = /^[a-z][a-z0-9-]{0,63}$/;
8
+ const SUFFIXES = [
9
+ ['.action.ts', 'action'],
10
+ ['.package.ts', 'package'],
11
+ ];
5
12
  const SKIP_DIRS = new Set(['node_modules', 'dist']);
6
13
  async function isFile(path) {
7
14
  try {
@@ -11,38 +18,41 @@ async function isFile(path) {
11
18
  return false;
12
19
  }
13
20
  }
14
- function exportsEntry(exp) {
15
- const dot = exp && typeof exp === 'object' && '.' in exp ? exp['.'] : exp;
16
- if (typeof dot === 'string')
17
- return dot;
18
- if (dot && typeof dot === 'object') {
19
- const c = dot;
20
- for (const k of ['import', 'default'])
21
- if (typeof c[k] === 'string')
22
- return c[k];
23
- }
24
- return undefined;
25
- }
26
- // A subdirectory with its own package.json is another package: its workflows go with its own push, not
27
- // this one's (pushing them from a monorepo root would upload them without their code).
28
- async function findWorkflowFiles(dir) {
29
- const out = [];
21
+ // A subdirectory with its own package.json is another package: its files go with its own push, not
22
+ // this one's (pushing them from a monorepo root would upload them without their dependencies).
23
+ async function walk(dir, out) {
30
24
  for (const e of await readdir(dir, { withFileTypes: true })) {
31
25
  const path = join(dir, e.name);
32
26
  if (e.isDirectory()) {
33
27
  if (SKIP_DIRS.has(e.name) || e.name.startsWith('.') || (await isFile(join(path, 'package.json'))))
34
28
  continue;
35
- out.push(...(await findWorkflowFiles(path)));
29
+ await walk(path, out);
30
+ }
31
+ else if (e.isFile()) {
32
+ if (e.name.endsWith('.workflow.json'))
33
+ out.workflowFiles.push(path);
34
+ if (e.name.endsWith('.workflow.ts'))
35
+ out.workflowTsFiles.push(path);
36
+ const match = SUFFIXES.find(([suffix]) => e.name.endsWith(suffix));
37
+ if (match)
38
+ out.artifacts.push({ name: e.name.slice(0, -match[0].length), kind: match[1], file: path });
36
39
  }
37
- else if (e.isFile() && e.name.endsWith('.workflow.json'))
38
- out.push(path);
39
40
  }
40
- return out;
41
41
  }
42
+ function suggestActionName(name) {
43
+ const s = name
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9-]+/g, '-')
46
+ .replace(/^[^a-z]+/, '')
47
+ .replace(/-+$/, '');
48
+ return s || 'my-action';
49
+ }
50
+ const byKindThenName = (a, b) => (a.kind === b.kind ? 0 : a.kind === 'package' ? -1 : 1) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
42
51
  /**
43
- * A package directory → what `offsida push` uploads. Artifact name: `offsida.name`, else the npm name
44
- * without its scope. Code entry: exports["."] → main → src/index.ts → index.ts (first that exists;
45
- * null = no code). Workflows: every *.workflow.json below, skipping node_modules, dist, dot-dirs and nested packages.
52
+ * A package directory → what `offsida push` uploads. Artifacts: every `<name>.action.ts` and
53
+ * `<name>.package.ts` below, each named after its file, packages first (an action may require a
54
+ * package's connection) then by name. Workflows: every *.workflow.json and *.workflow.ts below. Both skip node_modules,
55
+ * dist, dot-dirs and nested packages. Every naming problem is reported in one error.
46
56
  */
47
57
  export async function resolvePackage(dirArg) {
48
58
  const dir = resolve(dirArg);
@@ -61,22 +71,30 @@ export async function resolvePackage(dirArg) {
61
71
  catch {
62
72
  throw new Error(`${pkgPath}: not valid JSON`);
63
73
  }
64
- const override = pkg.offsida && typeof pkg.offsida.name === 'string' ? pkg.offsida.name : undefined;
65
- const npmName = typeof pkg.name === 'string' ? pkg.name.replace(/^@[^/]+\//, '') : undefined;
66
- const artifactName = override ?? npmName;
67
- if (!artifactName)
68
- throw new Error(`${pkgPath}: no "name" (or "offsida.name") to name the artifact`);
69
- if (!ARTIFACT_NAME.test(artifactName)) {
70
- throw new Error(`artifact name "${artifactName}" is not allowed (letters, digits, . _ -, max 64) — set "offsida": { "name": "…" } in package.json`);
74
+ const warnings = [];
75
+ if (pkg.offsida?.name !== undefined) {
76
+ warnings.push('package.json: "offsida.name" is ignored — artifacts are named after their *.action.ts / *.package.ts files');
71
77
  }
72
- const candidates = [exportsEntry(pkg.exports), typeof pkg.main === 'string' ? pkg.main : undefined, 'src/index.ts', 'index.ts'];
73
- let entry = null;
74
- for (const c of candidates) {
75
- if (c && (await isFile(join(dir, c)))) {
76
- entry = join(dir, c);
77
- break;
78
+ const found = { artifacts: [], workflowFiles: [], workflowTsFiles: [] };
79
+ await walk(dir, found);
80
+ const artifacts = found.artifacts.sort(byKindThenName);
81
+ const problems = [];
82
+ const seen = new Map();
83
+ for (const a of artifacts) {
84
+ const rel = relative(dir, a.file);
85
+ if (a.kind === 'action' && !ACTION_NAME.test(a.name)) {
86
+ problems.push(`${rel}: an action file name must be kebab-case (a-z, 0-9, -; starting with a letter; max 64) — it is also the action's name. Try ${suggestActionName(a.name)}.action.ts`);
87
+ }
88
+ else if (!ARTIFACT_NAME.test(a.name)) {
89
+ problems.push(`${rel}: "${a.name}" is not a valid artifact name (letters, digits, . _ -, max 64)`);
78
90
  }
91
+ const first = seen.get(a.name);
92
+ if (first)
93
+ problems.push(`"${a.name}" is declared by both ${first} and ${rel} — artifact names are per org, so each file name must be unique`);
94
+ else
95
+ seen.set(a.name, rel);
79
96
  }
80
- const workflowFiles = (await findWorkflowFiles(dir)).sort();
81
- return { dir, artifactName, entry, workflowFiles };
97
+ if (problems.length > 0)
98
+ throw new Error(problems.join('\n'));
99
+ return { dir, artifacts, workflowFiles: found.workflowFiles.sort(), workflowTsFiles: found.workflowTsFiles.sort(), warnings };
82
100
  }
@@ -1,4 +1,4 @@
1
- /** A `*.workflow.json` file. `id` pins one workflow in one org; only `name` + `definition` are sent. */
1
+ /** A `*.workflow.json` file, or a compiled `*.workflow.ts`. `id` pins one workflow in one org; only `name` + `definition` are sent. */
2
2
  export type WorkflowFile = {
3
3
  path: string;
4
4
  id?: string;
@@ -6,4 +6,6 @@ export type WorkflowFile = {
6
6
  definition: Record<string, unknown>;
7
7
  };
8
8
  export declare function readWorkflowFile(path: string): Promise<WorkflowFile>;
9
+ /** The shape checks, on an already-parsed value — shared by `*.workflow.json` and compiled `*.workflow.ts`. */
10
+ export declare function parseWorkflowFile(path: string, v: unknown): WorkflowFile;
9
11
  export declare function assertNoDuplicates(files: WorkflowFile[]): void;
@@ -17,6 +17,10 @@ export async function readWorkflowFile(path) {
17
17
  catch {
18
18
  throw new Error(`${path}: not valid JSON`);
19
19
  }
20
+ return parseWorkflowFile(path, v);
21
+ }
22
+ /** The shape checks, on an already-parsed value — shared by `*.workflow.json` and compiled `*.workflow.ts`. */
23
+ export function parseWorkflowFile(path, v) {
20
24
  if (!isObject(v))
21
25
  throw new Error(`${path}: expected a JSON object`);
22
26
  if (typeof v.name !== 'string' || !v.name.trim())
package/dist/workflows.js CHANGED
@@ -62,7 +62,61 @@ export async function findWorkflow(api, file, rows, pinned = new Set()) {
62
62
  throw new Error(`workflow ${id} changed while resolving "${file.name}" — retry`);
63
63
  return { kind: 'existing', id: wf.id, currentName: wf.name };
64
64
  }
65
- const CAPABILITIES = ['secrets', 'connections', 'egressDomains', 'artifacts', 'containerImages'];
65
+ const CAPABILITIES = ['secrets', 'connections', 'egressDomains', 'artifacts', 'containerImages', 'fileStores'];
66
+ /** `${stepId}:${storeId}:${mode}` (the CLI stays free of the contracts package, so this mirrors `parseFileStoreEntry`). */
67
+ function parseGrant(entry) {
68
+ const parts = entry.split(':');
69
+ const mode = parts.pop() ?? '';
70
+ const store = parts.pop() ?? '';
71
+ return { stepId: parts.join(':'), store, mode };
72
+ }
73
+ /** Every store id the definition binds, at any depth (nested steps included). */
74
+ function boundStoreIds(node, into = new Set()) {
75
+ if (Array.isArray(node))
76
+ for (const v of node)
77
+ boundStoreIds(v, into);
78
+ else if (node && typeof node === 'object') {
79
+ for (const [k, v] of Object.entries(node)) {
80
+ if (k === 'fileStoreBindings' && v && typeof v === 'object') {
81
+ for (const b of Object.values(v)) {
82
+ const id = b?.storeId;
83
+ if (typeof id === 'string')
84
+ into.add(id);
85
+ }
86
+ }
87
+ else
88
+ boundStoreIds(v, into);
89
+ }
90
+ }
91
+ return into;
92
+ }
93
+ /**
94
+ * The org's stores by id, best effort: `undefined` when the list cannot be read (a token without `files:list`, a network
95
+ * error) — the escalation is then still shown, by store id, with no SENSITIVE marker and a note that sensitivity is unknown.
96
+ */
97
+ async function loadStores(api) {
98
+ try {
99
+ const res = await call(api, 'GET', '/files');
100
+ const items = res.body?.items;
101
+ if (res.status !== 200 || !Array.isArray(items))
102
+ return undefined;
103
+ return new Map(items.map((s) => [s.storeId, { name: s.name, sensitive: s.sensitive }]));
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ /**
110
+ * One file-store grant, readably: `step <id>: <store name | run store> — <mode>`, with a SENSITIVE marker on a sensitive
111
+ * store and on a run-store grant in a workflow that holds a sensitive binding.
112
+ */
113
+ function grantLine(entry, stores, workflowSensitive) {
114
+ const { stepId, store, mode } = parseGrant(entry);
115
+ const info = stores.get(store);
116
+ const isRun = store === 'run';
117
+ const sensitive = isRun ? workflowSensitive : (info?.sensitive ?? false);
118
+ return `step ${stepId}: ${isRun ? 'run store' : (info?.name ?? store)} — ${mode}${sensitive ? ' [SENSITIVE]' : ''}`;
119
+ }
66
120
  const indent = (text) => text.split('\n').map((l) => ` ${l}`);
67
121
  /**
68
122
  * Save one workflow file as its workflow's draft and, with `publish`, publish it. Never throws: every
@@ -138,8 +192,20 @@ async function saveAndPublish(api, file, rows, publish, deps, say) {
138
192
  say('needs confirming in Offsida — the draft is uploaded but adds access:');
139
193
  for (const c of CAPABILITIES) {
140
194
  const added = b.escalation?.[c]?.added ?? [];
141
- if (added.length)
195
+ if (!added.length)
196
+ continue;
197
+ if (c !== 'fileStores') {
142
198
  say(` ${c}: ${added.join(', ')}`);
199
+ continue;
200
+ }
201
+ const loaded = await loadStores(api);
202
+ const stores = loaded ?? new Map();
203
+ const workflowSensitive = [...boundStoreIds(file.definition)].some((id) => stores.get(id)?.sensitive);
204
+ say(` ${c}:`);
205
+ for (const e of added)
206
+ say(` ${grantLine(e, stores, workflowSensitive)}`);
207
+ if (!loaded)
208
+ say(' (the store list could not be read, so sensitivity is unknown — confirm in the web UI)');
143
209
  }
144
210
  say(`confirm and publish at ${editorUrl}`);
145
211
  return 'confirm';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@offsida/cli",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "The `offsida` CLI — bundle an integration package and publish it to a running Offsida environment.",
6
6
  "license": "MIT",
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "esbuild-wasm": "^0.25.12",
40
- "@offsida/types": "0.2.0"
40
+ "@offsida/types": "0.3.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^25.9.3",