@sprid/cli 0.1.2

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +14 -0
  3. package/README-post.md +615 -0
  4. package/README.md +249 -0
  5. package/SECURITY.md +18 -0
  6. package/bin.mjs +12 -0
  7. package/package.json +47 -0
  8. package/src/args.mjs +134 -0
  9. package/src/browser.mjs +28 -0
  10. package/src/cli.mjs +257 -0
  11. package/src/commands/auth.mjs +192 -0
  12. package/src/commands/completion.mjs +90 -0
  13. package/src/commands/connect.mjs +586 -0
  14. package/src/commands/docs.mjs +53 -0
  15. package/src/commands/family.mjs +96 -0
  16. package/src/commands/init.mjs +137 -0
  17. package/src/commands/local-tools.mjs +31 -0
  18. package/src/commands/marketing-review.mjs +106 -0
  19. package/src/commands/mcp.mjs +53 -0
  20. package/src/commands/misc.mjs +94 -0
  21. package/src/commands/pinterest.mjs +120 -0
  22. package/src/commands/plan.mjs +230 -0
  23. package/src/commands/post.mjs +41 -0
  24. package/src/commands/reviews.mjs +149 -0
  25. package/src/commands/setup.mjs +220 -0
  26. package/src/commands/status.mjs +217 -0
  27. package/src/commands/studio.mjs +69 -0
  28. package/src/commands/update.mjs +69 -0
  29. package/src/creds.mjs +126 -0
  30. package/src/docs/commands.mjs +152 -0
  31. package/src/docs/guides.generated.mjs +1178 -0
  32. package/src/docs/help.mjs +77 -0
  33. package/src/docs/index.d.mts +18 -0
  34. package/src/docs/index.mjs +45 -0
  35. package/src/docs/queries.d.mts +11 -0
  36. package/src/docs/queries.mjs +77 -0
  37. package/src/endpoint.mjs +17 -0
  38. package/src/evidence.mjs +15 -0
  39. package/src/format.mjs +71 -0
  40. package/src/http.mjs +117 -0
  41. package/src/pending.mjs +27 -0
  42. package/src/post/cli.mjs +2285 -0
  43. package/src/post/json-worker.mjs +12 -0
  44. package/src/post/preview-server.mjs +58 -0
  45. package/src/post/preview.mjs +660 -0
  46. package/src/post/recipes/screen.mjs +290 -0
  47. package/src/post/recipes/stills.mjs +146 -0
  48. package/src/post/rules/platform-rules.d.mts +27 -0
  49. package/src/post/rules/platform-rules.mjs +164 -0
  50. package/src/post/screen/captions.mjs +131 -0
  51. package/src/post/screen/compose.mjs +284 -0
  52. package/src/post/screen/input.mjs +163 -0
  53. package/src/post/screen/sim.mjs +443 -0
  54. package/src/post/screen/simkit.swift +328 -0
  55. package/src/profiles.mjs +61 -0
  56. package/src/release.ts +2 -0
  57. package/src/screenshots.ts +1 -0
  58. package/src/updates.mjs +152 -0
@@ -0,0 +1,217 @@
1
+ import { readLocalApp } from "../profiles.mjs";
2
+ // status / next / queue. Every line here is one field of the StatusView the
3
+ // server computes; the web Home card and the Monday mail print the same list.
4
+ // This file only lays it out.
5
+
6
+ import { BAD, C, NONE, OK, capOf, dayTime, healthMark, hhmm, pad, table, wrap } from "../format.mjs";
7
+
8
+ const isHttp = (s) => typeof s === "string" && /^https?:\/\//.test(s);
9
+
10
+ async function fetchStatus(ctx) {
11
+ const app = ctx.flags.app || readLocalApp(ctx.cwd)?.data?.appId || readLocalApp(ctx.cwd)?.data?.slug;
12
+ return ctx.api().get(`/api/status?workspaceId=${await ctx.workspaceId()}${app ? `&app=${encodeURIComponent(app)}` : ""}${ctx.flags.account ? `&account=${encodeURIComponent(ctx.flags.account)}` : ""}`);
13
+ }
14
+
15
+ export async function status(ctx) {
16
+ const view = await fetchStatus(ctx);
17
+ if (ctx.json) {
18
+ ctx.out(view);
19
+ return 0;
20
+ }
21
+ // A person with several workspaces needs to know which dashboard this is.
22
+ const several = (await ctx.workspaces().catch(() => [])).length > 1;
23
+ ctx.print(renderStatus(view, { header: several }));
24
+ return 0;
25
+ }
26
+
27
+ /** The dashboard in the terminal (plan doc §4). */
28
+ export function renderStatus(view, { header = false } = {}) {
29
+ const lines = [];
30
+ const L = (label, body) => ` ${pad(label, 13)}${body}`;
31
+ if (header && view.workspace?.name) {
32
+ lines.push(` ${C.bold(view.workspace.name)} (${view.workspace.tier ?? view.workspace.slug})`);
33
+ }
34
+
35
+ // NEXT: top 3, numbered, with the cli line right-aligned in a column.
36
+ const top = (view.next ?? []).slice(0, 3);
37
+ if (top.length === 0) {
38
+ lines.push(L("NEXT", C.dim("nothing needs you")));
39
+ } else {
40
+ const rows = top.map((a, i) => [`${i + 1}`, a.title, C.dim(a.cli)]);
41
+ const t = table(rows, { indent: "", gap: 2 }).split("\n");
42
+ t.forEach((row, i) => {
43
+ lines.push(L(i === 0 ? "NEXT" : "", row));
44
+ if (isHttp(top[i].href)) lines.push(L("", ` → ${top[i].href}`));
45
+ });
46
+ if ((view.next ?? []).length > 3) lines.push(L("", C.dim(`+${view.next.length - 3} more · sprid next`)));
47
+ }
48
+
49
+ // CONNECTIONS: one line per account (channels), then one per App Profile
50
+ // (sensors). Flattened it read "instagram ✗ instagram ✓ instagram ✓" and
51
+ // nobody could tell which account was the broken one.
52
+ const conns = view.connections ?? [];
53
+ if (conns.length === 0) {
54
+ lines.push(L("CONNECTIONS", C.dim("none yet · sprid connect")));
55
+ } else {
56
+ const rows = connectionRows(conns);
57
+ const t = table(rows, { indent: "", gap: 2 }).split("\n");
58
+ t.forEach((row, i) => lines.push(L(i === 0 ? "CONNECTIONS" : "", row)));
59
+ }
60
+
61
+ // QUEUE
62
+ const q = view.queue ?? { scheduled: 0, nextAt: null, nextAccount: null, failed7d: 0 };
63
+ const next = q.nextAt ? `next ${dayTime(q.nextAt)}${q.nextAccount ? ` @${q.nextAccount}` : ""}` : "nothing scheduled";
64
+ lines.push(L("QUEUE", `${q.scheduled} scheduled · ${next} · ${q.failed7d} failed`));
65
+
66
+ // APPS: one line per App Profile, sensors as marks.
67
+ const apps = view.apps ?? [];
68
+ if (apps.length === 0) {
69
+ lines.push(L("APPS", C.dim("none · sprid init")));
70
+ } else {
71
+ const rows = apps.map((a) => [a.slug, sensorsLine(a.sensors, failingServices(conns, a.slug))]);
72
+ const t = table(rows, { indent: "", gap: 2 }).split("\n");
73
+ t.forEach((row, i) => lines.push(L(i === 0 ? "APPS" : "", row)));
74
+ }
75
+
76
+ // PLAN
77
+ const ws = view.workspace ?? {};
78
+ const u = view.usage ?? {};
79
+ const caps = view.caps ?? {};
80
+ // The plan as the server reads it (billing/plan.ts), not as the raw status
81
+ // column reads: "trial" alone never said how long was left, and "expired"
82
+ // means the fourteen days ran out rather than that anybody cancelled.
83
+ const plan = ws.plan ?? null;
84
+ const statusWord = plan
85
+ ? plan.state === "trialing"
86
+ ? ` trial, ${plan.daysLeft === 0 ? "last day" : `${plan.daysLeft}d left`}`
87
+ : plan.state === "trial_expired"
88
+ ? " trial ended"
89
+ : plan.state === "active"
90
+ ? ""
91
+ : ` ${plan.state.replace("_", " ")}`
92
+ : ws.status && ws.status !== "active"
93
+ ? ` ${ws.status.replace("_", " ")}`
94
+ : "";
95
+ lines.push(
96
+ L(
97
+ "PLAN",
98
+ `${ws.tierName ?? ws.tier ?? ""}${statusWord} · ${u.appProfiles ?? 0}/${capOf(caps.appProfiles)} apps · ${u.accounts ?? 0}/${capOf(caps.accounts)} accounts · ${u.channels ?? 0}/${capOf(caps.channels)} channels${byokWord(conns)}`,
99
+ ),
100
+ );
101
+ // One line under PLAN when the server has stopped, or is about to. It says
102
+ // the command, because a terminal user should never have to open a browser
103
+ // to find out what to type.
104
+ if (plan && (!plan.active || (plan.state === "trialing" && plan.endingSoon))) {
105
+ lines.push(L("", `${plan.reason} → sprid upgrade pulse`));
106
+ }
107
+ return lines.join("\n");
108
+ }
109
+
110
+ const PROVIDER_SERVICES = new Set(["anthropic", "openai", "fal", "google", "replicate"]);
111
+
112
+ /** ` · BYOK anthropic ✓` / ` · BYOK –` from the workspace-level provider-key rows; nothing when the server sends none. */
113
+ export function byokWord(conns = []) {
114
+ const rows = conns.filter((c) => c.kind === "sensor" && PROVIDER_SERVICES.has(c.service));
115
+ if (rows.length === 0) return "";
116
+ const ok = rows.filter((c) => c.health === "ok").map((c) => c.service);
117
+ return ok.length ? ` · BYOK ${ok.map((s) => `${s} ${OK}`).join(" ")}` : ` · BYOK ${NONE}`;
118
+ }
119
+
120
+ /** [scopeLabel, "service mark service mark …"] per account, then per App Profile. */
121
+ export function connectionRows(conns) {
122
+ const groups = new Map();
123
+ for (const c of conns) {
124
+ if (PROVIDER_SERVICES.has(c.service)) continue; // workspace-level; shown on the PLAN line
125
+ const isSensor = c.kind === "sensor";
126
+ const key = `${isSensor ? "1" : "0"}:${c.scope}`;
127
+ if (!groups.has(key)) {
128
+ // Sensors are labelled "App Store Connect · My App": the app's name is after the dot.
129
+ const name = isSensor ? (c.label?.split(" · ")[1] ?? c.scope) : c.scope;
130
+ groups.set(key, { name, cells: [] });
131
+ }
132
+ const g = groups.get(key);
133
+ const mark = healthMark(c.health);
134
+ const note = c.health === "revoked" || c.health === "expiring" ? ` (${c.health})` : "";
135
+ const time = isSensor && c.health === "ok" && c.lastOkAt ? ` ${hhmm(c.lastOkAt)}` : "";
136
+ g.cells.push(`${c.service} ${mark}${note}${time}`);
137
+ }
138
+ return [...groups.keys()].sort().map((k) => [groups.get(k).name, groups.get(k).cells.join(" ")]);
139
+ }
140
+
141
+ const SENSOR_ORDER = [
142
+ ["appStoreReviews", "asc"],
143
+ ["playReviews", "play"],
144
+ ["posthog", "posthog"],
145
+ ["revenuecat", "revenuecat"],
146
+ ["searchConsole", "gsc"],
147
+ ["cloudflare", "cloudflare"],
148
+ ];
149
+
150
+ /** Sensor services whose last pull failed (`health: "error"`) for one App Profile. */
151
+ export function failingServices(conns = [], slug) {
152
+ return new Set(conns.filter((c) => c.kind === "sensor" && c.scope === slug && c.health === "error").map((c) => c.service));
153
+ }
154
+
155
+ /** A key that is set but whose last pull failed shows ✗, not ✓. */
156
+ export function sensorsLine(sensors = {}, failing = new Set()) {
157
+ return SENSOR_ORDER.map(([k, label]) => `${label} ${failing.has(label) ? BAD : sensors[k] ? OK : NONE}`).join(" ");
158
+ }
159
+
160
+ export async function next(ctx) {
161
+ const params = new URLSearchParams({ workspaceId: String(await ctx.workspaceId()) });
162
+ if (ctx.flags.limit) params.set("limit", String(ctx.flags.limit));
163
+ const qs = params.toString();
164
+ const res = await ctx.api().get(`/api/next${qs ? `?${qs}` : ""}`);
165
+ const actions = res.actions ?? [];
166
+ if (ctx.json) {
167
+ ctx.out(res);
168
+ return 0;
169
+ }
170
+ if (actions.length === 0) {
171
+ ctx.print(" Nothing needs you.");
172
+ return 0;
173
+ }
174
+ const n = String(actions.length).length;
175
+ for (const [i, a] of actions.entries()) {
176
+ const num = String(i + 1).padStart(n);
177
+ ctx.print(` ${num} ${a.title}`);
178
+ const indent = " ".repeat(n + 5);
179
+ if (a.why) for (const l of wrap(a.why, 100)) ctx.print(C.dim(`${indent}${l}`));
180
+ if (a.fix) for (const l of wrap(`Fix: ${a.fix}`, 100)) ctx.print(`${indent}${l}`);
181
+ if (isHttp(a.href)) ctx.print(`${indent}${a.href}`);
182
+ if (a.cli) ctx.print(`${indent}${C.dim(a.cli)}`);
183
+ }
184
+ return 0;
185
+ }
186
+
187
+ export async function queue(ctx) {
188
+ const [sub, arg] = ctx.positionals;
189
+ if (sub === "retry") {
190
+ const id = Number(arg);
191
+ if (!Number.isInteger(id) || id <= 0) throw usage("sprid queue retry <publishId>");
192
+ const row = await ctx.api().post(`/api/publishes/${id}/retry`);
193
+ if (ctx.json) ctx.out({ ok: true, publish: row });
194
+ else ctx.print(` ${C.ok(OK)} Publish ${id} is back on the queue${row?.scheduledFor ? ` for ${dayTime(row.scheduledFor)}` : ""}.`);
195
+ return 0;
196
+ }
197
+ if (sub) throw usage("sprid queue [retry <publishId>]");
198
+ const view = await fetchStatus(ctx);
199
+ const q = view.queue ?? {};
200
+ if (ctx.json) {
201
+ ctx.out({ workspaceId: view.workspace?.id ?? null, queue: q, failed: (view.next ?? []).filter((a) => a.kind === "publish-failed") });
202
+ return 0;
203
+ }
204
+ const next = q.nextAt ? `next ${dayTime(q.nextAt)}${q.nextAccount ? ` @${q.nextAccount}` : ""}` : "nothing scheduled";
205
+ ctx.print(` ${q.scheduled ?? 0} scheduled · ${next} · ${q.failed7d ?? 0} failed in 7 days`);
206
+ const failed = (view.next ?? []).filter((a) => a.kind === "publish-failed");
207
+ for (const f of failed) ctx.print(` ${C.bad("✗")} ${f.title} ${C.dim(f.cli)}`);
208
+ const dry = (view.next ?? []).find((a) => a.kind === "queue-dry");
209
+ if (dry) ctx.print(` ${C.warn(NONE)} ${dry.title} ${C.dim(dry.cli)}`);
210
+ return 0;
211
+ }
212
+
213
+ function usage(s) {
214
+ const e = new Error(`Usage: ${s}`);
215
+ e.exitCode = 2;
216
+ return e;
217
+ }
@@ -0,0 +1,69 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { basename, resolve, extname } from 'node:path';
3
+ import { randomUUID, createHash } from 'node:crypto';
4
+ import { UsageError } from '../args.mjs';
5
+ import { localPost } from './post.mjs';
6
+
7
+ const show = (ctx, result) => ctx.json ? ctx.out(result) : ctx.print(JSON.stringify(result, null, 2));
8
+ async function payload(ctx) {
9
+ if (typeof ctx.flags.file !== 'string') throw new UsageError('Supply --file <payload.json>.');
10
+ return JSON.parse(await readFile(resolve(ctx.cwd, ctx.flags.file), 'utf8'));
11
+ }
12
+ export async function remotePost(ctx) {
13
+ const [verb, ref] = ctx.positionals;
14
+ const id = ctx.flags.id ?? ref;
15
+ if (!['create', 'get', 'update', 'deliveries', 'handoff'].includes(verb) && !(verb === 'preview' && ctx.flags.id)) return false;
16
+ if (verb !== 'create' && !/^[1-9]\d*$/.test(String(id))) throw new UsageError('Supply the positive server post ID. Local preview slugs remain unchanged.');
17
+ const api = ctx.api();
18
+ const result = verb === 'create' ? await api.post('/api/studio/posts', await payload(ctx))
19
+ : verb === 'update' ? await api.patch(`/api/posts/${id}`, await payload(ctx))
20
+ : verb === 'preview' ? await api.get(`/api/studio/posts/${id}/preview`)
21
+ : verb === 'deliveries' ? await api.get(`/api/studio/posts/${id}/publications`)
22
+ // The prompt you paste into an agent. Built on the server so this and the
23
+ // editor's "Hand off to agent" button print the same string.
24
+ : verb === 'handoff' ? await api.get(`/api/posts/${id}/handoff`)
25
+ : await api.get(`/api/posts/${id}`);
26
+ // Human output is the prompt itself, not a JSON envelope around it: the
27
+ // point of the command is that its stdout is pasteable.
28
+ if (verb === 'handoff' && !ctx.json) { ctx.out ? console.log(result.prompt) : console.log(result.prompt); return true; }
29
+ show(ctx, result); return true;
30
+ }
31
+ export async function capabilities(ctx) { show(ctx, await ctx.api().get('/api/studio/capabilities')); return 0; }
32
+ export async function media(ctx) {
33
+ const [verb, ...files] = ctx.positionals;
34
+ if (!verb || verb === 'help' || ctx.flags.help) {
35
+ show(ctx, { usage: 'sprid media upload <file...> --account <slug> [--request-id UUID] | import --file sources.json | reel --file recipe.json | job <UUID>',
36
+ local: 'sprid media init|new|build|register|check|push|draft|sync|pull|preview|status|doctor …',
37
+ guide: 'sprid docs local', aliases: 'Historical local sprid post commands remain supported. Shared server preview uses sprid post preview --id <postId>.' });
38
+ return 0;
39
+ }
40
+ if (verb === 'job') {
41
+ if (!files[0] || !/^[0-9a-f-]{36}$/i.test(files[0])) throw new UsageError('sprid media job <requestId>');
42
+ show(ctx, await ctx.api().get(`/api/studio/jobs/${files[0]}`)); return 0;
43
+ }
44
+ if (verb === 'import') { show(ctx, await ctx.api().post('/api/studio/assets/import', await payload(ctx))); return 0; }
45
+ if (verb === 'reel') { show(ctx, await ctx.api().post('/api/studio/reels', await payload(ctx))); return 0; }
46
+ if (verb !== 'upload') return localPost(ctx);
47
+ if (!files.length || typeof ctx.flags.account !== 'string') throw new UsageError('sprid media upload <file...> --account <slug> [--request-id UUID]');
48
+ if (files.length > 20) throw new UsageError('Upload at most 20 files per session.');
49
+ const requestId = ctx.flags['request-id'] ?? randomUUID();
50
+ // Print the retry key before the first network call; a disconnect can always resume.
51
+ ctx.warn(`Upload request ID: ${requestId}`);
52
+ const api = ctx.api();
53
+ await api.post('/api/studio/uploads', { account: ctx.flags.account, requestId });
54
+ const assets = [];
55
+ for (const [position, file] of files.entries()) {
56
+ if ((await stat(resolve(ctx.cwd, file))).size > 32 * 1024 * 1024) throw new UsageError(`${file} exceeds 32 MiB. Use the existing local push pipeline for larger videos.`);
57
+ const bytes = await readFile(resolve(ctx.cwd, file));
58
+ const contentType = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.mp4': 'video/mp4' }[extname(file).toLowerCase()];
59
+ if (!contentType) throw new UsageError(`Unsupported media: ${file}`);
60
+ const slot = await api.post(`/api/studio/uploads/${requestId}/files`, { position, filename: basename(file), contentType, sizeBytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') });
61
+ if (slot.ready) { assets.push(slot.asset); continue; }
62
+ const res = await (ctx.fetch ?? fetch)(slot.uploadUrl, { method: 'PUT', headers: slot.headers, body: bytes, redirect: 'error', signal: AbortSignal.timeout(120000) });
63
+ if (!res.ok) throw new Error(`File ${position + 1} upload failed (${res.status}); retry with --request-id ${requestId}.`);
64
+ const confirmed = await api.post(`/api/studio/uploads/${requestId}/files/${position}/confirm`, {});
65
+ assets.push(confirmed.asset);
66
+ }
67
+ show(ctx, { requestId, account: ctx.flags.account, assets, next: 'Create a draft with sprid post create --file draft.json. Use kind/id from these assets in their selected order.' });
68
+ return 0;
69
+ }
@@ -0,0 +1,69 @@
1
+ import { checkVersion, detectInstallation, autoEnabled, setAuto, performUpdate, installationCommand } from '../updates.mjs';
2
+ import { UsageError } from '../args.mjs';
3
+
4
+ function validate(ctx, allowed) {
5
+ if (ctx.positionals.length) throw new UsageError('Unexpected argument. Use sprid help for update syntax.');
6
+ for (const [key, value] of Object.entries(ctx.flags)) {
7
+ if (!allowed.includes(key)) throw new UsageError(`Unknown option --${key}. No update attempted.`);
8
+ if (key !== 'auto' && value !== true) throw new UsageError(`--${key} takes no value. No update attempted.`);
9
+ }
10
+ }
11
+
12
+ async function report(ctx, force = false) {
13
+ const options = { env: ctx.env, ...ctx.updateOptions };
14
+ const [version, installation] = await Promise.all([
15
+ checkVersion({ ...options, current: ctx.version, offline: Boolean(ctx.flags.offline), force }),
16
+ detectInstallation(options),
17
+ ]);
18
+ return { ...version, installation, auto: autoEnabled(installation, ctx.env),
19
+ updateCommand: installation.kind === 'unsupported' ? null : `sprid update${version.requiresApproval ? ' --yes' : ''}`,
20
+ install: installationCommand(installation, version.recommended) };
21
+ }
22
+ function output(ctx, result) {
23
+ if (ctx.json) return ctx.out(result);
24
+ if (result.configured !== undefined) return ctx.print(`Automatic compatible updates: ${result.configured ? 'on' : 'off'} for this installation.`);
25
+ ctx.print(`Sprid ${result.current}. ${result.check === 'verified' ? `Recommended: ${result.recommended}.` : 'Version check unavailable; ordinary work can continue.'}`);
26
+ if (result.updated) ctx.print(`Updated to ${result.version}. Start a new CLI process before continuing.`);
27
+ else if (result.updateAvailable) ctx.warn(`${result.requiresApproval ? 'Potentially breaking update. ' : ''}${result.updateCommand || result.installation.reason}`);
28
+ else if (result.installation.kind === 'unsupported') ctx.print(result.installation.reason);
29
+ if (result.skipped) ctx.print(`Update skipped: ${result.skipped}.`);
30
+ }
31
+ export async function doctor(ctx) {
32
+ validate(ctx, ['json', 'offline', 'apply-updates']);
33
+ const result = await report(ctx);
34
+ // This flag marks an explicit between-jobs boundary. Ordinary commands never install.
35
+ if (ctx.flags['apply-updates'] && result.auto && result.check === 'verified' && result.compatible && result.installation.kind !== 'unsupported') {
36
+ const { 'apply-updates': _, ...flags } = ctx.flags;
37
+ return update({ ...ctx, flags: { ...flags, 'auto-run': true } });
38
+ }
39
+ output(ctx, result);
40
+ return 0;
41
+ }
42
+ export async function update(ctx) {
43
+ validate(ctx, ['json', 'offline', 'auto', 'auto-run', 'check', 'yes']);
44
+ if (ctx.flags.auto !== undefined) {
45
+ if (!['on', 'off'].includes(ctx.flags.auto)) throw new UsageError('Use sprid update --auto on|off.');
46
+ if (ctx.flags['auto-run'] || ctx.flags.check) throw new UsageError('--auto cannot be combined with --auto-run or --check.');
47
+ const installation = await detectInstallation({ env: ctx.env, ...ctx.updateOptions });
48
+ setAuto(installation, ctx.flags.auto === 'on', ctx.env);
49
+ output(ctx, { configured: ctx.flags.auto === 'on', installation });
50
+ return 0;
51
+ }
52
+ const result = await report(ctx, !ctx.flags.check);
53
+ if (ctx.flags.check) { output(ctx, result); return 0; }
54
+ if (ctx.flags['auto-run'] && (!result.auto || !result.compatible || result.check !== 'verified' || result.installation.kind === 'unsupported')) {
55
+ output(ctx, { ...result, skipped: !result.auto ? 'automatic updates are off' : 'no verified compatible update' }); return 0;
56
+ }
57
+ if (result.check !== 'verified') throw new Error('Cannot verify the recommended version. No update attempted; retry when the registry is available.');
58
+ if (!result.updateAvailable) { output(ctx, result); return 0; }
59
+ if (result.installation.kind === 'unsupported') throw new UsageError(result.installation.reason);
60
+ if (result.requiresApproval && !ctx.flags.yes) throw new UsageError('Potentially breaking update. Review the release, then run sprid update --yes to approve it.');
61
+ const updated = await performUpdate(result.installation, result.recommended, { env: ctx.env, ...ctx.updateOptions });
62
+ output(ctx, { ...result, ...updated });
63
+ return 0;
64
+ }
65
+
66
+ export async function notifyUpdate(ctx) {
67
+ const version = await checkVersion({ current: ctx.version, env: ctx.env });
68
+ if (version.check === 'verified' && version.updateAvailable) ctx.warn(`Sprid ${version.recommended} is available${version.requiresApproval ? ' (potentially breaking)' : ''}. Run sprid doctor for update instructions.`);
69
+ }
package/src/creds.mjs ADDED
@@ -0,0 +1,126 @@
1
+ // Credentials: where the token lives and how every command finds it.
2
+ //
3
+ // SPRID_PAT wins. An agent, a CI job or a workspace-pinned batch session
4
+ // sets it and the login file is never consulted.
5
+ // the file ~/.sprid/credentials.json, written by `sprid login`, 0600 in
6
+ // a 0700 dir. { apiUrl, token, tokenId, workspace, email? }.
7
+ // SPRID_URL overrides the API base wherever the token came from.
8
+ //
9
+ // Nothing else reads the token. The CLI is the one trusted local process that
10
+ // opens key files, so a secret never has to pass through an agent's context.
11
+
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { apiUrl as validateApiUrl } from './endpoint.mjs';
16
+
17
+ export const DEFAULT_API_URL = "https://api.sprid.studio";
18
+
19
+ export function credentialsPath(env = process.env) {
20
+ if (env.SPRID_CREDENTIALS_FILE) return env.SPRID_CREDENTIALS_FILE;
21
+ return join(env.HOME || homedir(), ".sprid", "credentials.json");
22
+ }
23
+
24
+ export function secretsPath(slug, env = process.env) {
25
+ if (env.SPRID_SECRETS_FILE) return env.SPRID_SECRETS_FILE;
26
+ if (!/^[a-z0-9-]+$/.test(slug)) throw new Error('Invalid app slug.');
27
+ return join(env.HOME || homedir(), ".sprid", "secrets", `${slug}.json`);
28
+ }
29
+
30
+ export function readCredentials(env = process.env) {
31
+ const p = credentialsPath(env);
32
+ if (!existsSync(p)) return null;
33
+ try {
34
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
35
+ // The file may hold only a `workspace` (written by `sprid use` for an
36
+ // SPRID_PAT user); a token is not required for it to count.
37
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export function writeCredentials(creds, env = process.env) {
44
+ const p = credentialsPath(env);
45
+ const dir = dirname(p);
46
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
47
+ try {
48
+ chmodSync(dir, 0o700);
49
+ } catch {
50
+ // Windows has no POSIX modes; the write below still lands.
51
+ }
52
+ writeFileSync(p, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
53
+ try {
54
+ chmodSync(p, 0o600);
55
+ } catch {
56
+ // see above
57
+ }
58
+ return p;
59
+ }
60
+
61
+ export function deleteCredentials(env = process.env) {
62
+ const p = credentialsPath(env);
63
+ if (existsSync(p)) rmSync(p);
64
+ return p;
65
+ }
66
+
67
+ /** True when the file is readable by nobody but its owner (POSIX only; true elsewhere). */
68
+ export function credentialsModeOk(env = process.env) {
69
+ const p = credentialsPath(env);
70
+ if (!existsSync(p)) return true;
71
+ if (process.platform === "win32") return true;
72
+ return (statSync(p).mode & 0o077) === 0;
73
+ }
74
+
75
+ export class NotLoggedIn extends Error {
76
+ constructor() {
77
+ super("Not logged in. Run `sprid login`, or set SPRID_PAT to a token from Settings → Tokens.");
78
+ this.name = "NotLoggedIn";
79
+ this.exitCode = 1;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The token, its source, and the API base.
85
+ * `flags.api` is `sprid login --api`; for every other command SPRID_URL is the
86
+ * only override, so a stored dev login is not silently sent to production.
87
+ */
88
+ export function resolveAuth(env = process.env, flags = {}) {
89
+ const file = readCredentials(env);
90
+ const envToken = env.SPRID_PAT && env.SPRID_PAT.trim();
91
+ const apiUrl = validateApiUrl(env.SPRID_URL || flags.api || (envToken ? DEFAULT_API_URL : file?.apiUrl) || DEFAULT_API_URL);
92
+ if (envToken) {
93
+ // The token is the environment's; the chosen workspace (`sprid use`) may still be the file's.
94
+ return { token: envToken, source: "env", apiUrl, workspace: file?.workspace ?? null, tokenId: null, email: null };
95
+ }
96
+ if (file && typeof file.token === "string") {
97
+ return {
98
+ token: file.token,
99
+ source: "file",
100
+ apiUrl,
101
+ workspace: file.workspace ?? null,
102
+ tokenId: file.tokenId ?? null,
103
+ email: file.email ?? null,
104
+ };
105
+ }
106
+ throw new NotLoggedIn();
107
+ }
108
+
109
+ /** app.sprid.studio for api.sprid.studio; SPRID_APP_URL wins; a dev API keeps its own origin. */
110
+ export function appUrlFor(apiUrl, env = process.env) {
111
+ if (env.SPRID_APP_URL) return stripSlash(env.SPRID_APP_URL);
112
+ try {
113
+ const u = new URL(apiUrl);
114
+ if (u.hostname.startsWith("api.")) {
115
+ u.hostname = "app." + u.hostname.slice(4);
116
+ return stripSlash(u.origin);
117
+ }
118
+ return stripSlash(u.origin);
119
+ } catch {
120
+ return "https://app.sprid.studio";
121
+ }
122
+ }
123
+
124
+ export function stripSlash(s) {
125
+ return String(s).replace(/\/+$/, "");
126
+ }