@offsida/cli 0.1.1 → 0.2.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/api.d.ts +20 -0
- package/dist/api.js +74 -0
- package/dist/auth.d.ts +11 -0
- package/dist/auth.js +40 -0
- package/dist/bundle.d.ts +3 -0
- package/dist/bundle.js +6 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +126 -39
- package/dist/push-directory.d.ts +18 -0
- package/dist/push-directory.js +86 -0
- package/dist/push.d.ts +4 -0
- package/dist/push.js +17 -51
- package/dist/resolve-package.d.ts +12 -0
- package/dist/resolve-package.js +79 -0
- package/dist/workflow-file.d.ts +9 -0
- package/dist/workflow-file.js +48 -0
- package/dist/workflows.d.ts +42 -0
- package/dist/workflows.js +148 -0
- package/package.json +5 -1
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type AuthOpts } from './auth.js';
|
|
2
|
+
export type Api = {
|
|
3
|
+
url: string;
|
|
4
|
+
org: string;
|
|
5
|
+
headers: Record<string, string>;
|
|
6
|
+
fetch: typeof fetch;
|
|
7
|
+
};
|
|
8
|
+
export type Res = {
|
|
9
|
+
status: number;
|
|
10
|
+
body: unknown;
|
|
11
|
+
};
|
|
12
|
+
export declare function connect(opts: AuthOpts, fetchImpl?: typeof fetch): Promise<Api>;
|
|
13
|
+
export declare function call(api: Api, method: string, path: string, init?: {
|
|
14
|
+
body?: unknown;
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
}): Promise<Res>;
|
|
17
|
+
export declare function errorCode(res: Res): string | undefined;
|
|
18
|
+
export declare function failure(operation: string, res: Res): Error;
|
|
19
|
+
export declare function isRetryable(res: Res): boolean;
|
|
20
|
+
export declare function withRetry(send: () => Promise<Res>, sleep: (ms: number) => Promise<void>): Promise<Res>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ORG_HEADER } from '@offsida/types';
|
|
2
|
+
import { assertTarget, authHeaders } from './auth.js';
|
|
3
|
+
export async function connect(opts, fetchImpl = fetch) {
|
|
4
|
+
assertTarget(opts);
|
|
5
|
+
const auth = await authHeaders(opts, fetchImpl);
|
|
6
|
+
return { url: opts.url, org: opts.org, headers: { ...auth, [ORG_HEADER]: opts.org }, fetch: fetchImpl };
|
|
7
|
+
}
|
|
8
|
+
// Never throws on status: callers branch on it. A non-JSON body (a proxy's HTML 502) reads as undefined.
|
|
9
|
+
export async function call(api, method, path, init) {
|
|
10
|
+
const headers = { ...api.headers, ...init?.headers };
|
|
11
|
+
if (init?.body !== undefined)
|
|
12
|
+
headers['content-type'] = 'application/json';
|
|
13
|
+
const res = await api.fetch(`${api.url}/api${path}`, {
|
|
14
|
+
method,
|
|
15
|
+
headers,
|
|
16
|
+
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
|
|
17
|
+
});
|
|
18
|
+
const text = await res.text();
|
|
19
|
+
let body = undefined;
|
|
20
|
+
try {
|
|
21
|
+
body = text ? JSON.parse(text) : undefined;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
body = undefined;
|
|
25
|
+
}
|
|
26
|
+
return { status: res.status, body };
|
|
27
|
+
}
|
|
28
|
+
export function errorCode(res) {
|
|
29
|
+
const b = (res.body ?? {});
|
|
30
|
+
if (b.error && typeof b.error === 'object' && typeof b.error.code === 'string') {
|
|
31
|
+
return b.error.code;
|
|
32
|
+
}
|
|
33
|
+
return typeof b.code === 'string' ? b.code : undefined;
|
|
34
|
+
}
|
|
35
|
+
// Reads both error envelopes: AppError `{ error: { code, message } }`, and the idempotency/validation
|
|
36
|
+
// `{ error: string, code?, field? }`.
|
|
37
|
+
export function failure(operation, res) {
|
|
38
|
+
if (res.status === 401)
|
|
39
|
+
return new Error('auth rejected (HTTP 401) — token invalid/revoked, or login expired');
|
|
40
|
+
if (res.status === 403)
|
|
41
|
+
return new Error(`forbidden (HTTP 403) — ${operation} needs a token with the right scope`);
|
|
42
|
+
let message = `${operation} failed (HTTP ${res.status})`;
|
|
43
|
+
const b = (res.body ?? {});
|
|
44
|
+
if (b.error && typeof b.error === 'object') {
|
|
45
|
+
const e = b.error;
|
|
46
|
+
message += `: ${String(e.code)}: ${String(e.message)}`;
|
|
47
|
+
}
|
|
48
|
+
else if (typeof b.error === 'string') {
|
|
49
|
+
message += `: ${b.error}`;
|
|
50
|
+
if (typeof b.field === 'string')
|
|
51
|
+
message += ` (field: ${b.field})`;
|
|
52
|
+
}
|
|
53
|
+
return new Error(message);
|
|
54
|
+
}
|
|
55
|
+
export function isRetryable(res) {
|
|
56
|
+
if (errorCode(res) === 'idempotency_in_progress')
|
|
57
|
+
return true;
|
|
58
|
+
if (res.status !== 503 && res.status !== 409)
|
|
59
|
+
return false;
|
|
60
|
+
const e = res.body?.error;
|
|
61
|
+
return !!e && typeof e === 'object' && e.retryable === true;
|
|
62
|
+
}
|
|
63
|
+
const BACKOFF_MS = [500, 1500];
|
|
64
|
+
// Up to three attempts. The caller's `send` reuses one Idempotency-Key, so the server deduplicates.
|
|
65
|
+
export async function withRetry(send, sleep) {
|
|
66
|
+
let res = await send();
|
|
67
|
+
for (const ms of BACKOFF_MS) {
|
|
68
|
+
if (!isRetryable(res))
|
|
69
|
+
break;
|
|
70
|
+
await sleep(ms);
|
|
71
|
+
res = await send();
|
|
72
|
+
}
|
|
73
|
+
return res;
|
|
74
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type AuthOpts = {
|
|
2
|
+
url: string;
|
|
3
|
+
org: string;
|
|
4
|
+
token?: string;
|
|
5
|
+
email?: string;
|
|
6
|
+
code?: string;
|
|
7
|
+
allowRemote?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function isLocal(url: string): boolean;
|
|
10
|
+
export declare function assertTarget(opts: AuthOpts): void;
|
|
11
|
+
export declare function authHeaders(opts: AuthOpts, fetchImpl?: typeof fetch): Promise<Record<string, string>>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function isLocal(url) {
|
|
2
|
+
try {
|
|
3
|
+
const h = new URL(url).hostname;
|
|
4
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0';
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
// A non-local URL is refused without an explicit opt-in, so a stray OFFSIDA_URL cannot quietly
|
|
11
|
+
// push to production.
|
|
12
|
+
export function assertTarget(opts) {
|
|
13
|
+
if (!opts.org)
|
|
14
|
+
throw new Error('set --org or OFFSIDA_ORG');
|
|
15
|
+
if (!opts.token && !(opts.email && opts.code)) {
|
|
16
|
+
throw new Error('set OFFSIDA_TOKEN (CI), or OFFSIDA_EMAIL and OFFSIDA_CODE (interactive)');
|
|
17
|
+
}
|
|
18
|
+
if (!isLocal(opts.url) && !opts.allowRemote) {
|
|
19
|
+
throw new Error(`refusing to push to non-local URL ${opts.url} without --allow-remote (or OFFSIDA_ALLOW_REMOTE=1)`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Two auth modes:
|
|
23
|
+
// • token — non-interactive (CI): `Authorization: Bearer <osk_…>`, an org API token (Story 022).
|
|
24
|
+
// • email+code — interactive: exchange an emailed login code for a session cookie.
|
|
25
|
+
export async function authHeaders(opts, fetchImpl = fetch) {
|
|
26
|
+
if (opts.token)
|
|
27
|
+
return { authorization: `Bearer ${opts.token}` };
|
|
28
|
+
const verify = await fetchImpl(`${opts.url}/api/auth/verify`, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'content-type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({ email: opts.email, code: opts.code }),
|
|
32
|
+
redirect: 'manual',
|
|
33
|
+
});
|
|
34
|
+
if (!verify.ok)
|
|
35
|
+
throw new Error(`login failed: HTTP ${verify.status}`);
|
|
36
|
+
const cookie = (verify.headers.get('set-cookie') ?? '').match(/session=[^;]+/)?.[0];
|
|
37
|
+
if (!cookie)
|
|
38
|
+
throw new Error('login did not return a session cookie');
|
|
39
|
+
return { cookie };
|
|
40
|
+
}
|
package/dist/bundle.d.ts
CHANGED
package/dist/bundle.js
CHANGED
|
@@ -13,9 +13,14 @@ async function ensureInit() {
|
|
|
13
13
|
// prefixes (e.g. the `@core` workspace alias) — esbuild rewrites subpaths, so
|
|
14
14
|
// `{ '@core': '/abs/lib/core/src' }` maps `@core/services/x` → `/abs/lib/core/src/services/x`.
|
|
15
15
|
export async function bundlePackage(dir, opts) {
|
|
16
|
+
return bundleEntry(join(dir, 'index.ts'), opts);
|
|
17
|
+
}
|
|
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).
|
|
20
|
+
export async function bundleEntry(entryFile, opts) {
|
|
16
21
|
await ensureInit();
|
|
17
22
|
const result = await esbuild.build({
|
|
18
|
-
entryPoints: [
|
|
23
|
+
entryPoints: [entryFile],
|
|
19
24
|
bundle: true,
|
|
20
25
|
format: 'esm',
|
|
21
26
|
platform: 'neutral',
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
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. 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\nExit codes: 0 done, 1 something failed, 2 something needs confirming in Offsida.";
|
|
4
|
+
type RunDeps = PushDirectoryDeps & {
|
|
5
|
+
out?: (s: string) => void;
|
|
6
|
+
err?: (s: string) => void;
|
|
7
|
+
};
|
|
8
|
+
export declare function run(argv: string[], env: NodeJS.ProcessEnv, deps?: RunDeps): Promise<number>;
|
|
2
9
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -1,47 +1,134 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
2
3
|
import { writeFile } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { bundleEntry } from './bundle.js';
|
|
6
|
+
import { pushDirectory } from './push-directory.js';
|
|
7
|
+
import { resolvePackage } from './resolve-package.js';
|
|
8
|
+
export const USAGE = `usage:
|
|
9
|
+
offsida push [dir] [--publish] [--url <u>] [--org <id>] [--allow-remote]
|
|
10
|
+
offsida build [dir] [--out <file>]
|
|
11
|
+
|
|
12
|
+
dir defaults to the current directory and must contain a package.json.
|
|
13
|
+
|
|
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. A file's optional "id" pins one workflow in one org; without it the
|
|
18
|
+
workflow is matched by name or created. --publish also publishes each workflow;
|
|
19
|
+
"latest" steps lock to the code just pushed.
|
|
20
|
+
|
|
21
|
+
A first publish goes live with no capability confirmation (offsida.web#14). A later
|
|
22
|
+
publish that adds access is left for confirming in Offsida (exit 2). Every push mints
|
|
23
|
+
a new code revision, so --publish republishes "latest" workflows (offsida.web#15).
|
|
24
|
+
|
|
25
|
+
Auth (environment only): OFFSIDA_TOKEN, or OFFSIDA_EMAIL + OFFSIDA_CODE.
|
|
26
|
+
Scopes: code:publish; workflows:list, :get, :create, :update; workflows:publish with --publish.
|
|
27
|
+
Also: OFFSIDA_URL (default http://localhost:3808), OFFSIDA_ORG, OFFSIDA_ALLOW_REMOTE=1.
|
|
28
|
+
|
|
29
|
+
Limits: name matching reads a list that lags a few seconds, so two quick pushes of a
|
|
30
|
+
new workflow can create two. Same-named workflows in different packages overwrite
|
|
31
|
+
each other without "id". Deleting a *.workflow.json leaves its workflow live.
|
|
32
|
+
|
|
33
|
+
Exit codes: 0 done, 1 something failed, 2 something needs confirming in Offsida.`;
|
|
34
|
+
// Each command's flags: `true` takes a value, `false` is a switch. Anything else is refused — in
|
|
35
|
+
// particular `--token`/`--code`, so a secret never has to sit in argv or shell history.
|
|
36
|
+
const FLAGS = {
|
|
37
|
+
push: { '--publish': false, '--url': true, '--org': true, '--allow-remote': false },
|
|
38
|
+
build: { '--out': true },
|
|
39
|
+
};
|
|
40
|
+
// Flags the package-rooted `push` dropped, and where each one went.
|
|
41
|
+
const REMOVED = {
|
|
42
|
+
'--token': 'set OFFSIDA_TOKEN (or OFFSIDA_EMAIL + OFFSIDA_CODE) in the environment',
|
|
43
|
+
'--email': 'set OFFSIDA_EMAIL and OFFSIDA_CODE in the environment',
|
|
44
|
+
'--code': 'set OFFSIDA_EMAIL and OFFSIDA_CODE in the environment',
|
|
45
|
+
'--name': 'set "offsida": { "name": "…" } in package.json',
|
|
46
|
+
};
|
|
47
|
+
class UsageError extends Error {
|
|
8
48
|
}
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
|
|
49
|
+
function parse(command, args) {
|
|
50
|
+
const known = FLAGS[command];
|
|
51
|
+
const flags = new Map();
|
|
52
|
+
const positional = [];
|
|
53
|
+
for (let i = 0; i < args.length; i++) {
|
|
54
|
+
const a = args[i];
|
|
55
|
+
const flag = a.split('=')[0]; // never echo a `--token=…` value back
|
|
56
|
+
if (!a.startsWith('--'))
|
|
57
|
+
positional.push(a);
|
|
58
|
+
else if (flag in REMOVED)
|
|
59
|
+
throw new UsageError(`${flag} was removed: ${REMOVED[flag]}`);
|
|
60
|
+
else if (!(a in known))
|
|
61
|
+
throw new UsageError(`unknown flag: ${flag}`);
|
|
62
|
+
else if (known[a]) {
|
|
63
|
+
const v = args[++i];
|
|
64
|
+
if (v === undefined)
|
|
65
|
+
throw new Error(`${a} needs a value`);
|
|
66
|
+
flags.set(a, v);
|
|
19
67
|
}
|
|
20
|
-
else
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
68
|
+
else
|
|
69
|
+
flags.set(a, true);
|
|
70
|
+
}
|
|
71
|
+
if (positional.length > 1)
|
|
72
|
+
throw new Error(`unknown argument: ${positional[1]}`);
|
|
73
|
+
return { dir: positional[0] ?? process.cwd(), flags };
|
|
74
|
+
}
|
|
75
|
+
export async function run(argv, env, deps = {}) {
|
|
76
|
+
const out = deps.out ?? ((s) => void process.stdout.write(`${s}\n`));
|
|
77
|
+
const err = deps.err ?? ((s) => void process.stderr.write(`${s}\n`));
|
|
78
|
+
const [command, ...args] = argv;
|
|
79
|
+
if (!command || argv.includes('--help') || argv.includes('-h')) {
|
|
80
|
+
out(USAGE);
|
|
81
|
+
return 0;
|
|
24
82
|
}
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
83
|
+
if (!(command in FLAGS)) {
|
|
84
|
+
err(`unknown command: ${command}\n\n${USAGE}`);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const { dir, flags } = parse(command, args);
|
|
89
|
+
if (command === 'build') {
|
|
90
|
+
const pkg = await resolvePackage(dir);
|
|
91
|
+
if (!pkg.entry)
|
|
92
|
+
throw new Error(`${dir}: no code entry to build`);
|
|
93
|
+
const code = await bundleEntry(pkg.entry);
|
|
94
|
+
const file = flags.get('--out');
|
|
95
|
+
if (typeof file === 'string') {
|
|
96
|
+
await writeFile(file, code);
|
|
97
|
+
out(`wrote ${file}`);
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
out(code);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
const url = flags.get('--url');
|
|
104
|
+
const org = flags.get('--org');
|
|
105
|
+
return await pushDirectory({
|
|
29
106
|
dir,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
email:
|
|
36
|
-
code:
|
|
37
|
-
|
|
38
|
-
allowRemote: flags.includes('--allow-remote') || process.env.OFFSIDA_ALLOW_REMOTE === '1',
|
|
39
|
-
});
|
|
40
|
-
return;
|
|
107
|
+
publish: flags.has('--publish'),
|
|
108
|
+
url: typeof url === 'string' ? url : (env.OFFSIDA_URL ?? 'http://localhost:3808'),
|
|
109
|
+
org: typeof org === 'string' ? org : (env.OFFSIDA_ORG ?? ''),
|
|
110
|
+
allowRemote: flags.has('--allow-remote') || env.OFFSIDA_ALLOW_REMOTE === '1',
|
|
111
|
+
token: env.OFFSIDA_TOKEN,
|
|
112
|
+
email: env.OFFSIDA_EMAIL,
|
|
113
|
+
code: env.OFFSIDA_CODE,
|
|
114
|
+
}, deps);
|
|
41
115
|
}
|
|
42
|
-
|
|
116
|
+
catch (e) {
|
|
117
|
+
err(e instanceof UsageError ? `${e.message}\n\n${USAGE}` : e.message);
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Run only when executed as the binary (which may be a symlink), not when imported by a test.
|
|
122
|
+
function isMain() {
|
|
123
|
+
try {
|
|
124
|
+
return !!process.argv[1] && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (isMain()) {
|
|
131
|
+
run(process.argv.slice(2), process.env).then((code) => {
|
|
132
|
+
process.exitCode = code;
|
|
133
|
+
});
|
|
43
134
|
}
|
|
44
|
-
main().catch((e) => {
|
|
45
|
-
process.stderr.write(`${e.message}\n`);
|
|
46
|
-
process.exit(1);
|
|
47
|
-
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AuthOpts } from './auth.js';
|
|
2
|
+
export type PushDirectoryOpts = AuthOpts & {
|
|
3
|
+
dir: string;
|
|
4
|
+
publish?: boolean;
|
|
5
|
+
};
|
|
6
|
+
export type PushDirectoryDeps = {
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
log?: (line: string) => void;
|
|
9
|
+
sleep?: (ms: number) => Promise<void>;
|
|
10
|
+
idempotencyKey?: () => string;
|
|
11
|
+
};
|
|
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
|
|
15
|
+
* is pushed (pre-flight, target, auth). After that every problem is reported and the result is the worst
|
|
16
|
+
* outcome: 1 failed, 2 needs confirming in Offsida, 0 done.
|
|
17
|
+
*/
|
|
18
|
+
export declare function pushDirectory(opts: PushDirectoryOpts, deps?: PushDirectoryDeps): Promise<0 | 1 | 2>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import { connect } from './api.js';
|
|
3
|
+
import { bundleEntry } from './bundle.js';
|
|
4
|
+
import { pushCode } from './push.js';
|
|
5
|
+
import { resolvePackage } from './resolve-package.js';
|
|
6
|
+
import { assertNoDuplicates, readWorkflowFile } from './workflow-file.js';
|
|
7
|
+
import { listWorkflows, pushWorkflow } from './workflows.js';
|
|
8
|
+
const SUMMARY = [
|
|
9
|
+
['saved', 'saved'],
|
|
10
|
+
['published', 'published'],
|
|
11
|
+
['unchanged', 'unchanged'],
|
|
12
|
+
['confirm', 'needs confirming in Offsida'],
|
|
13
|
+
['failed', 'failed'],
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
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
|
|
18
|
+
* is pushed (pre-flight, target, auth). After that every problem is reported and the result is the worst
|
|
19
|
+
* outcome: 1 failed, 2 needs confirming in Offsida, 0 done.
|
|
20
|
+
*/
|
|
21
|
+
export async function pushDirectory(opts, deps = {}) {
|
|
22
|
+
const log = deps.log ?? ((line) => void process.stdout.write(`${line}\n`));
|
|
23
|
+
const workflowDeps = {
|
|
24
|
+
sleep: deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
|
|
25
|
+
idempotencyKey: deps.idempotencyKey ?? (() => crypto.randomUUID()),
|
|
26
|
+
pinned: new Set(),
|
|
27
|
+
};
|
|
28
|
+
// Pre-flight: everything that can be checked locally, before any request.
|
|
29
|
+
const pkg = await resolvePackage(opts.dir);
|
|
30
|
+
const files = [];
|
|
31
|
+
for (const path of pkg.workflowFiles)
|
|
32
|
+
files.push(await readWorkflowFile(path));
|
|
33
|
+
assertNoDuplicates(files);
|
|
34
|
+
for (const f of files)
|
|
35
|
+
if (f.id !== undefined)
|
|
36
|
+
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`);
|
|
39
|
+
const api = await connect(opts, deps.fetch);
|
|
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) {
|
|
43
|
+
try {
|
|
44
|
+
const { revision } = await pushCode(api, pkg.artifactName, await bundleEntry(pkg.entry));
|
|
45
|
+
log(`pushed code ${pkg.artifactName}@${revision}`);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
log(`error: ${e.message}`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (files.length === 0)
|
|
53
|
+
return 0;
|
|
54
|
+
// One list per run, and only when some file is matched by name.
|
|
55
|
+
let rows = [];
|
|
56
|
+
let listError;
|
|
57
|
+
if (files.some((f) => f.id === undefined)) {
|
|
58
|
+
try {
|
|
59
|
+
rows = await listWorkflows(api);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
listError = e;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const outcomes = [];
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
if (listError && file.id === undefined) {
|
|
68
|
+
log(relative(process.cwd(), file.path));
|
|
69
|
+
log(` error: ${listError.message}`);
|
|
70
|
+
outcomes.push('failed');
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const { outcome, lines } = await pushWorkflow(api, file, rows, !!opts.publish, workflowDeps);
|
|
74
|
+
for (const line of lines)
|
|
75
|
+
log(line);
|
|
76
|
+
outcomes.push(outcome);
|
|
77
|
+
}
|
|
78
|
+
const counts = SUMMARY.map(([o, label]) => [outcomes.filter((x) => x === o).length, label]).filter(([n]) => n > 0);
|
|
79
|
+
const n = outcomes.length;
|
|
80
|
+
log(`${n} workflow${n === 1 ? '' : 's'}: ${counts.map(([c, label]) => `${c} ${label}`).join(', ')}`);
|
|
81
|
+
if (outcomes.includes('failed'))
|
|
82
|
+
return 1;
|
|
83
|
+
if (outcomes.includes('confirm'))
|
|
84
|
+
return 2;
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
package/dist/push.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Api } from './api.js';
|
|
1
2
|
export type PushOpts = {
|
|
2
3
|
dir: string;
|
|
3
4
|
url: string;
|
|
@@ -8,4 +9,7 @@ export type PushOpts = {
|
|
|
8
9
|
name?: string;
|
|
9
10
|
allowRemote?: boolean;
|
|
10
11
|
};
|
|
12
|
+
export declare function pushCode(api: Api, name: string, code: string): Promise<{
|
|
13
|
+
revision: string;
|
|
14
|
+
}>;
|
|
11
15
|
export declare function pushPackage(opts: PushOpts): Promise<void>;
|
package/dist/push.js
CHANGED
|
@@ -1,58 +1,24 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
|
-
import { ORG_HEADER } from '@offsida/types';
|
|
3
2
|
import { bundlePackage } from './bundle.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
import { call, connect, failure } from './api.js';
|
|
4
|
+
// Upload one bundle as a new revision of artifact `name`. The build runs synchronously server-side:
|
|
5
|
+
// the response is `ready` or `failed`.
|
|
6
|
+
export async function pushCode(api, name, code) {
|
|
7
|
+
const res = await call(api, 'POST', '/code', { body: { name, modules: { 'index.js': code } } });
|
|
8
|
+
if (res.status === 401 || res.status === 403)
|
|
9
|
+
throw failure('pushing code', res);
|
|
10
|
+
const body = (res.body ?? {});
|
|
11
|
+
if (res.status !== 201 || !body.ok || !body.revision) {
|
|
12
|
+
throw new Error(`code push failed (${body.status ?? res.status}): ${body.failureReason ?? 'unknown'}`);
|
|
11
13
|
}
|
|
14
|
+
return { revision: body.revision };
|
|
12
15
|
}
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// • email+code — interactive: exchange an emailed login code for a session cookie, then upload.
|
|
17
|
-
// A non-local --url is refused without an explicit --allow-remote opt-in (both modes).
|
|
16
|
+
// Low-level: bundle <dir>/index.ts and push it as artifact `name` (default: the dir's basename). No
|
|
17
|
+
// package.json involved — used by lib/integrations/scripts/dev-push.ts. The CLI's `push` command is
|
|
18
|
+
// package-rooted instead (push-directory.ts).
|
|
18
19
|
export async function pushPackage(opts) {
|
|
19
|
-
|
|
20
|
-
throw new Error('--org is required');
|
|
21
|
-
if (!opts.token && !(opts.email && opts.code))
|
|
22
|
-
throw new Error('provide --token (CI), or --email and --code (interactive)');
|
|
23
|
-
if (!isLocal(opts.url) && !opts.allowRemote) {
|
|
24
|
-
throw new Error(`refusing to push to non-local URL ${opts.url} without --allow-remote (or OFFSIDA_ALLOW_REMOTE=1)`);
|
|
25
|
-
}
|
|
20
|
+
const api = await connect(opts);
|
|
26
21
|
const name = opts.name ?? basename(opts.dir);
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const authHeaders = {};
|
|
30
|
-
if (opts.token) {
|
|
31
|
-
authHeaders['authorization'] = `Bearer ${opts.token}`;
|
|
32
|
-
}
|
|
33
|
-
else {
|
|
34
|
-
const verify = await fetch(`${opts.url}/api/auth/verify`, {
|
|
35
|
-
method: 'POST',
|
|
36
|
-
headers: { 'content-type': 'application/json' },
|
|
37
|
-
body: JSON.stringify({ email: opts.email, code: opts.code }),
|
|
38
|
-
redirect: 'manual',
|
|
39
|
-
});
|
|
40
|
-
if (!verify.ok)
|
|
41
|
-
throw new Error(`login failed: HTTP ${verify.status}`);
|
|
42
|
-
const cookie = (verify.headers.get('set-cookie') ?? '').match(/session=[^;]+/)?.[0];
|
|
43
|
-
if (!cookie)
|
|
44
|
-
throw new Error('login did not return a session cookie');
|
|
45
|
-
authHeaders['cookie'] = cookie;
|
|
46
|
-
}
|
|
47
|
-
const res = await fetch(`${opts.url}/api/code`, {
|
|
48
|
-
method: 'POST',
|
|
49
|
-
headers: { 'content-type': 'application/json', [ORG_HEADER]: opts.org, ...authHeaders },
|
|
50
|
-
body: JSON.stringify({ name, modules: { 'index.js': code } }),
|
|
51
|
-
});
|
|
52
|
-
if (res.status === 401)
|
|
53
|
-
throw new Error('auth rejected (HTTP 401) — token invalid/revoked, or login expired');
|
|
54
|
-
const body = (await res.json());
|
|
55
|
-
if (!body.ok)
|
|
56
|
-
throw new Error(`publish failed (${body.status ?? res.status}): ${body.failureReason ?? 'unknown'}`);
|
|
57
|
-
process.stdout.write(`pushed ${name}@${body.version} — ${body.status}\n`);
|
|
22
|
+
const { revision } = await pushCode(api, name, await bundlePackage(opts.dir));
|
|
23
|
+
process.stdout.write(`pushed ${name}@${revision} — ready\n`);
|
|
58
24
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type ResolvedPackage = {
|
|
2
|
+
dir: string;
|
|
3
|
+
artifactName: string;
|
|
4
|
+
entry: string | null;
|
|
5
|
+
workflowFiles: string[];
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
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 and dot-dirs.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolvePackage(dirArg: string): Promise<ResolvedPackage>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
// The platform's artifact name rule (lib/contracts/src/schemas/code.ts publishBody).
|
|
4
|
+
const ARTIFACT_NAME = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
5
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist']);
|
|
6
|
+
async function isFile(path) {
|
|
7
|
+
try {
|
|
8
|
+
return (await stat(path)).isFile();
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
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
|
+
async function findWorkflowFiles(dir) {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
29
|
+
const path = join(dir, e.name);
|
|
30
|
+
if (e.isDirectory()) {
|
|
31
|
+
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.'))
|
|
32
|
+
out.push(...(await findWorkflowFiles(path)));
|
|
33
|
+
}
|
|
34
|
+
else if (e.isFile() && e.name.endsWith('.workflow.json'))
|
|
35
|
+
out.push(path);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A package directory → what `offsida push` uploads. Artifact name: `offsida.name`, else the npm name
|
|
41
|
+
* without its scope. Code entry: exports["."] → main → src/index.ts → index.ts (first that exists;
|
|
42
|
+
* null = no code). Workflows: every *.workflow.json below, skipping node_modules, dist and dot-dirs.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolvePackage(dirArg) {
|
|
45
|
+
const dir = resolve(dirArg);
|
|
46
|
+
const pkgPath = join(dir, 'package.json');
|
|
47
|
+
let raw;
|
|
48
|
+
try {
|
|
49
|
+
raw = await readFile(pkgPath, 'utf8');
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new Error(`${dirArg}: no package.json — offsida push runs in a package directory`);
|
|
53
|
+
}
|
|
54
|
+
let pkg;
|
|
55
|
+
try {
|
|
56
|
+
pkg = JSON.parse(raw);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error(`${pkgPath}: not valid JSON`);
|
|
60
|
+
}
|
|
61
|
+
const override = pkg.offsida && typeof pkg.offsida.name === 'string' ? pkg.offsida.name : undefined;
|
|
62
|
+
const npmName = typeof pkg.name === 'string' ? pkg.name.replace(/^@[^/]+\//, '') : undefined;
|
|
63
|
+
const artifactName = override ?? npmName;
|
|
64
|
+
if (!artifactName)
|
|
65
|
+
throw new Error(`${pkgPath}: no "name" (or "offsida.name") to name the artifact`);
|
|
66
|
+
if (!ARTIFACT_NAME.test(artifactName)) {
|
|
67
|
+
throw new Error(`artifact name "${artifactName}" is not allowed (letters, digits, . _ -, max 64) — set "offsida": { "name": "…" } in package.json`);
|
|
68
|
+
}
|
|
69
|
+
const candidates = [exportsEntry(pkg.exports), typeof pkg.main === 'string' ? pkg.main : undefined, 'src/index.ts', 'index.ts'];
|
|
70
|
+
let entry = null;
|
|
71
|
+
for (const c of candidates) {
|
|
72
|
+
if (c && (await isFile(join(dir, c)))) {
|
|
73
|
+
entry = join(dir, c);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const workflowFiles = (await findWorkflowFiles(dir)).sort();
|
|
78
|
+
return { dir, artifactName, entry, workflowFiles };
|
|
79
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A `*.workflow.json` file. `id` pins one workflow in one org; only `name` + `definition` are sent. */
|
|
2
|
+
export type WorkflowFile = {
|
|
3
|
+
path: string;
|
|
4
|
+
id?: string;
|
|
5
|
+
name: string;
|
|
6
|
+
definition: Record<string, unknown>;
|
|
7
|
+
};
|
|
8
|
+
export declare function readWorkflowFile(path: string): Promise<WorkflowFile>;
|
|
9
|
+
export declare function assertNoDuplicates(files: WorkflowFile[]): void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
3
|
+
// Shape checks only — the server owns full definition validation. The name is trimmed, as the server
|
|
4
|
+
// trims it, so matching by name and the duplicate check compare what Offsida actually stores.
|
|
5
|
+
export async function readWorkflowFile(path) {
|
|
6
|
+
let raw;
|
|
7
|
+
try {
|
|
8
|
+
raw = await readFile(path, 'utf8');
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw new Error(`${path}: cannot read`);
|
|
12
|
+
}
|
|
13
|
+
let v;
|
|
14
|
+
try {
|
|
15
|
+
v = JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error(`${path}: not valid JSON`);
|
|
19
|
+
}
|
|
20
|
+
if (!isObject(v))
|
|
21
|
+
throw new Error(`${path}: expected a JSON object`);
|
|
22
|
+
if (typeof v.name !== 'string' || !v.name.trim())
|
|
23
|
+
throw new Error(`${path}: "name" must be a non-empty string`);
|
|
24
|
+
if (!isObject(v.definition))
|
|
25
|
+
throw new Error(`${path}: "definition" must be an object`);
|
|
26
|
+
if ('id' in v && (typeof v.id !== 'string' || !v.id))
|
|
27
|
+
throw new Error(`${path}: "id", when present, must be a non-empty string`);
|
|
28
|
+
return { path, ...(typeof v.id === 'string' ? { id: v.id } : {}), name: v.name.trim(), definition: v.definition };
|
|
29
|
+
}
|
|
30
|
+
// Two files aimed at the same workflow would silently overwrite each other, so refuse the run.
|
|
31
|
+
export function assertNoDuplicates(files) {
|
|
32
|
+
const byId = new Map();
|
|
33
|
+
const byName = new Map();
|
|
34
|
+
for (const f of files) {
|
|
35
|
+
if (f.id !== undefined) {
|
|
36
|
+
const prev = byId.get(f.id);
|
|
37
|
+
if (prev)
|
|
38
|
+
throw new Error(`${prev.path} and ${f.path} both have "id": "${f.id}"`);
|
|
39
|
+
byId.set(f.id, f);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const prev = byName.get(f.name);
|
|
43
|
+
if (prev)
|
|
44
|
+
throw new Error(`${prev.path} and ${f.path} are both named "${f.name}" — add "id" to one, or rename it`);
|
|
45
|
+
byName.set(f.name, f);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Api } from './api.js';
|
|
2
|
+
import type { WorkflowFile } from './workflow-file.js';
|
|
3
|
+
export type WorkflowRow = {
|
|
4
|
+
workflow_id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
updated_at: number;
|
|
7
|
+
};
|
|
8
|
+
export type Target = {
|
|
9
|
+
kind: 'existing';
|
|
10
|
+
id: string;
|
|
11
|
+
currentName: string;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'new';
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Every workflow row in the org (the D1 index — it can lag the DO by a few seconds). The list has no
|
|
17
|
+
* `nextCursor` and its cursor is a strict `updated_at <`, so rows sharing a timestamp could fall between
|
|
18
|
+
* pages; paging from `last + 1` makes the cursor inclusive and the overlap is de-duplicated by id.
|
|
19
|
+
*/
|
|
20
|
+
export declare function listWorkflows(api: Api): Promise<WorkflowRow[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Which workflow a file writes. An `id` is authoritative (a 404 fails — ids are minted per org, so there
|
|
23
|
+
* is nothing to create and a name fallback would defeat the pin). Without one, match the name exactly
|
|
24
|
+
* against `rows`, and confirm a single match against the workflow itself (DO-backed) before writing.
|
|
25
|
+
* `pinned` holds the ids other files in this run pin: a name match landing on one would overwrite it.
|
|
26
|
+
*/
|
|
27
|
+
export declare function findWorkflow(api: Api, file: WorkflowFile, rows: WorkflowRow[], pinned?: ReadonlySet<string>): Promise<Target>;
|
|
28
|
+
export type Outcome = 'saved' | 'published' | 'unchanged' | 'confirm' | 'failed';
|
|
29
|
+
export type WorkflowDeps = {
|
|
30
|
+
sleep: (ms: number) => Promise<void>;
|
|
31
|
+
idempotencyKey: () => string;
|
|
32
|
+
pinned?: ReadonlySet<string>;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Save one workflow file as its workflow's draft and, with `publish`, publish it. Never throws: every
|
|
36
|
+
* problem becomes `outcome: 'failed'` with an `error:` line, so one workflow can't stop the others.
|
|
37
|
+
* `lines[0]` is the file's path; the rest are indented under it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function pushWorkflow(api: Api, file: WorkflowFile, rows: WorkflowRow[], publish: boolean, deps: WorkflowDeps): Promise<{
|
|
40
|
+
outcome: Outcome;
|
|
41
|
+
lines: string[];
|
|
42
|
+
}>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import { call, failure, withRetry } from './api.js';
|
|
3
|
+
const PAGE = 200; // the server's limit cap
|
|
4
|
+
/**
|
|
5
|
+
* Every workflow row in the org (the D1 index — it can lag the DO by a few seconds). The list has no
|
|
6
|
+
* `nextCursor` and its cursor is a strict `updated_at <`, so rows sharing a timestamp could fall between
|
|
7
|
+
* pages; paging from `last + 1` makes the cursor inclusive and the overlap is de-duplicated by id.
|
|
8
|
+
*/
|
|
9
|
+
export async function listWorkflows(api) {
|
|
10
|
+
const rows = new Map();
|
|
11
|
+
let cursor;
|
|
12
|
+
for (;;) {
|
|
13
|
+
const res = await call(api, 'GET', `/workflows?limit=${PAGE}${cursor === undefined ? '' : `&cursor=${cursor}`}`);
|
|
14
|
+
if (res.status !== 200)
|
|
15
|
+
throw failure('listing workflows', res);
|
|
16
|
+
const items = (res.body?.items ?? []);
|
|
17
|
+
const before = rows.size;
|
|
18
|
+
for (const r of items)
|
|
19
|
+
rows.set(r.workflow_id, r);
|
|
20
|
+
if (items.length < PAGE || rows.size === before)
|
|
21
|
+
break;
|
|
22
|
+
cursor = items[items.length - 1].updated_at + 1;
|
|
23
|
+
}
|
|
24
|
+
return [...rows.values()];
|
|
25
|
+
}
|
|
26
|
+
async function readWorkflow(api, id) {
|
|
27
|
+
const res = await call(api, 'GET', `/workflows/${encodeURIComponent(id)}`);
|
|
28
|
+
const wf = res.body?.workflow;
|
|
29
|
+
return { res, wf };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Which workflow a file writes. An `id` is authoritative (a 404 fails — ids are minted per org, so there
|
|
33
|
+
* is nothing to create and a name fallback would defeat the pin). Without one, match the name exactly
|
|
34
|
+
* against `rows`, and confirm a single match against the workflow itself (DO-backed) before writing.
|
|
35
|
+
* `pinned` holds the ids other files in this run pin: a name match landing on one would overwrite it.
|
|
36
|
+
*/
|
|
37
|
+
export async function findWorkflow(api, file, rows, pinned = new Set()) {
|
|
38
|
+
if (file.id !== undefined) {
|
|
39
|
+
const { res, wf } = await readWorkflow(api, file.id);
|
|
40
|
+
if (res.status === 404) {
|
|
41
|
+
throw new Error(`no workflow ${file.id} in this org — "id" ties a file to one org; fix it, or remove it to match by name`);
|
|
42
|
+
}
|
|
43
|
+
if (res.status !== 200 || !wf)
|
|
44
|
+
throw failure('reading workflow', res);
|
|
45
|
+
return { kind: 'existing', id: wf.id, currentName: wf.name };
|
|
46
|
+
}
|
|
47
|
+
const matches = rows.filter((r) => r.name === file.name);
|
|
48
|
+
if (matches.length === 0)
|
|
49
|
+
return { kind: 'new' };
|
|
50
|
+
if (matches.length > 1) {
|
|
51
|
+
const lines = matches.map((m) => ` ${m.workflow_id} (updated ${new Date(m.updated_at).toISOString()})`);
|
|
52
|
+
throw new Error([`${matches.length} workflows are named "${file.name}" — add "id" to ${file.path} to pick one:`, ...lines].join('\n'));
|
|
53
|
+
}
|
|
54
|
+
const id = matches[0].workflow_id;
|
|
55
|
+
if (pinned.has(id)) {
|
|
56
|
+
throw new Error(`workflow ${id} (named "${file.name}") is pinned by another file's "id" — rename this one, or give it its own "id"`);
|
|
57
|
+
}
|
|
58
|
+
const { res, wf } = await readWorkflow(api, id);
|
|
59
|
+
if (res.status !== 200 || !wf)
|
|
60
|
+
throw failure('reading workflow', res);
|
|
61
|
+
if (wf.name !== file.name)
|
|
62
|
+
throw new Error(`workflow ${id} changed while resolving "${file.name}" — retry`);
|
|
63
|
+
return { kind: 'existing', id: wf.id, currentName: wf.name };
|
|
64
|
+
}
|
|
65
|
+
const CAPABILITIES = ['secrets', 'connections', 'egressDomains', 'artifacts', 'containerImages'];
|
|
66
|
+
const indent = (text) => text.split('\n').map((l) => ` ${l}`);
|
|
67
|
+
/**
|
|
68
|
+
* Save one workflow file as its workflow's draft and, with `publish`, publish it. Never throws: every
|
|
69
|
+
* problem becomes `outcome: 'failed'` with an `error:` line, so one workflow can't stop the others.
|
|
70
|
+
* `lines[0]` is the file's path; the rest are indented under it.
|
|
71
|
+
*/
|
|
72
|
+
export async function pushWorkflow(api, file, rows, publish, deps) {
|
|
73
|
+
const lines = [relative(process.cwd(), file.path)];
|
|
74
|
+
const say = (text) => lines.push(...indent(text));
|
|
75
|
+
try {
|
|
76
|
+
const outcome = await saveAndPublish(api, file, rows, publish, deps, say);
|
|
77
|
+
return { outcome, lines };
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
say(`error: ${e.message}`);
|
|
81
|
+
return { outcome: 'failed', lines };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function saveAndPublish(api, file, rows, publish, deps, say) {
|
|
85
|
+
const target = await findWorkflow(api, file, rows, deps.pinned);
|
|
86
|
+
const body = { name: file.name, definition: file.definition };
|
|
87
|
+
// The file always wins: a full replace with no If-Match (spec decision 6).
|
|
88
|
+
let res;
|
|
89
|
+
if (target.kind === 'new') {
|
|
90
|
+
const key = deps.idempotencyKey();
|
|
91
|
+
res = await withRetry(() => call(api, 'POST', '/workflows', { body, headers: { 'Idempotency-Key': key } }), deps.sleep);
|
|
92
|
+
if (res.status !== 201)
|
|
93
|
+
throw failure('saving workflow', res);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
res = await call(api, 'PUT', `/workflows/${encodeURIComponent(target.id)}`, { body });
|
|
97
|
+
if (res.status !== 200)
|
|
98
|
+
throw failure('saving workflow', res);
|
|
99
|
+
}
|
|
100
|
+
const saved = res.body;
|
|
101
|
+
const id = saved.workflow.id;
|
|
102
|
+
const editorUrl = `${api.url}/org/${api.org}/workflows/${id}/edit`;
|
|
103
|
+
if (target.kind === 'new') {
|
|
104
|
+
say(`created workflow ${id} "${file.name}"`);
|
|
105
|
+
say(`add "id": "${id}" to this file so renames update this workflow (ids are per org)`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
say(`updated workflow ${id} "${file.name}"${target.currentName !== file.name ? ` (renamed from "${target.currentName}")` : ''}`);
|
|
109
|
+
}
|
|
110
|
+
say(editorUrl);
|
|
111
|
+
for (const d of saved.diagnostics ?? [])
|
|
112
|
+
say(`warning: [${d.code}] ${d.path}: ${d.message}`);
|
|
113
|
+
if (!publish)
|
|
114
|
+
return 'saved';
|
|
115
|
+
// No body: the CLI never acknowledges a capability escalation (spec decision 7).
|
|
116
|
+
const key = deps.idempotencyKey();
|
|
117
|
+
res = await withRetry(() => call(api, 'POST', `/workflows/${encodeURIComponent(id)}/publish`, { headers: { 'Idempotency-Key': key } }), deps.sleep);
|
|
118
|
+
const b = (res.body ?? {});
|
|
119
|
+
if (res.status === 201) {
|
|
120
|
+
say(`published revision ${b.publishedRevision}`);
|
|
121
|
+
for (const a of b.resolvedArtifacts ?? []) {
|
|
122
|
+
const was = a.previousRevision !== null && a.previousRevision !== a.revision ? ` (was ${a.previousRevision})` : '';
|
|
123
|
+
say(` ${a.stepId}: ${a.artifactName}@${a.revision}${was}`);
|
|
124
|
+
}
|
|
125
|
+
return 'published';
|
|
126
|
+
}
|
|
127
|
+
if (res.status === 200) {
|
|
128
|
+
say(`unchanged — already published as revision ${b.publishedRevision}`);
|
|
129
|
+
return 'unchanged';
|
|
130
|
+
}
|
|
131
|
+
if (res.status === 422 && Array.isArray(b.diagnostics)) {
|
|
132
|
+
say('publish blocked — the draft is uploaded but not live:');
|
|
133
|
+
for (const d of b.diagnostics.filter((x) => x.blocksPublish))
|
|
134
|
+
say(` [${d.code}] ${d.path}: ${d.message}`);
|
|
135
|
+
return 'failed';
|
|
136
|
+
}
|
|
137
|
+
if (res.status === 428) {
|
|
138
|
+
say('needs confirming in Offsida — the draft is uploaded but adds access:');
|
|
139
|
+
for (const c of CAPABILITIES) {
|
|
140
|
+
const added = b.escalation?.[c]?.added ?? [];
|
|
141
|
+
if (added.length)
|
|
142
|
+
say(` ${c}: ${added.join(', ')}`);
|
|
143
|
+
}
|
|
144
|
+
say(`confirm and publish at ${editorUrl}`);
|
|
145
|
+
return 'confirm';
|
|
146
|
+
}
|
|
147
|
+
throw failure('publishing workflow', res);
|
|
148
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@offsida/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"./push": {
|
|
21
21
|
"types": "./dist/push.d.ts",
|
|
22
22
|
"default": "./dist/push.js"
|
|
23
|
+
},
|
|
24
|
+
"./push-directory": {
|
|
25
|
+
"types": "./dist/push-directory.d.ts",
|
|
26
|
+
"default": "./dist/push-directory.js"
|
|
23
27
|
}
|
|
24
28
|
},
|
|
25
29
|
"files": [
|