@sequenceholdings/artifact-studio 0.1.6 → 0.1.9

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 (49) hide show
  1. package/dist/api.js +66 -9
  2. package/dist/auth.js +1 -1
  3. package/dist/build-subprocess.d.ts +15 -0
  4. package/dist/build-subprocess.js +65 -0
  5. package/dist/build-worker.d.ts +1 -0
  6. package/dist/build-worker.js +28 -0
  7. package/dist/build.d.ts +11 -0
  8. package/dist/build.js +85 -14
  9. package/dist/cli.d.ts +1 -0
  10. package/dist/cli.js +486 -118
  11. package/dist/config.d.ts +1 -0
  12. package/dist/config.js +4 -1
  13. package/dist/dev-link.d.ts +60 -0
  14. package/dist/dev-link.js +155 -0
  15. package/dist/dev-lock.d.ts +5 -0
  16. package/dist/dev-lock.js +83 -0
  17. package/dist/git-clone.d.ts +41 -0
  18. package/dist/git-clone.js +178 -0
  19. package/dist/git-service-client.d.ts +32 -0
  20. package/dist/git-service-client.js +122 -0
  21. package/dist/hash.d.ts +12 -0
  22. package/dist/hash.js +13 -1
  23. package/dist/lockfile-origin.d.ts +6 -0
  24. package/dist/lockfile-origin.js +41 -0
  25. package/dist/manifest-custom-roles.d.ts +63 -0
  26. package/dist/manifest-custom-roles.js +194 -0
  27. package/dist/manifest.d.ts +31 -0
  28. package/dist/manifest.js +77 -0
  29. package/dist/prepare-build.d.ts +21 -0
  30. package/dist/prepare-build.js +94 -0
  31. package/dist/project.js +2 -1
  32. package/dist/sandbox-lint.d.ts +21 -0
  33. package/dist/sandbox-lint.js +95 -0
  34. package/dist/sanitize-remote-tree.d.ts +19 -0
  35. package/dist/sanitize-remote-tree.js +52 -0
  36. package/dist/sdk.d.ts +49 -0
  37. package/dist/source-resolver.d.ts +68 -0
  38. package/dist/source-resolver.js +202 -0
  39. package/dist/templates/react-vite/CLAUDE.md +18 -7
  40. package/dist/templates/react-vite/package.json +8 -7
  41. package/dist/trusted-install.d.ts +13 -0
  42. package/dist/trusted-install.js +186 -0
  43. package/dist/watch-loop.d.ts +26 -0
  44. package/dist/watch-loop.js +31 -0
  45. package/package.json +29 -9
  46. package/templates/react-vite/CLAUDE.md +18 -7
  47. package/templates/react-vite/package.json +8 -7
  48. package/dist/bin.d.ts +0 -2
  49. package/dist/bin.js +0 -8
package/dist/cli.js CHANGED
@@ -1,33 +1,54 @@
1
1
  import { cp, mkdir, writeFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
- import { dirname, join, resolve } from 'node:path';
4
- import { execFileSync } from 'node:child_process';
3
+ import { dirname, join, relative, resolve } from 'node:path';
5
4
  import { fileURLToPath } from 'node:url';
6
5
  import { getJson, getJsonOr404, postJson } from './api.js';
7
6
  import { readLocalConfig, resolveEnvironment, writeLocalConfig, writeTokenConfig, } from './config.js';
8
7
  import { buildArtifactStudioProject, writeBuildArtifact } from './build.js';
8
+ import { buildArtifactStudioProjectIsolated } from './build-subprocess.js';
9
+ import { prepareArtifactBuildRoot } from './prepare-build.js';
10
+ import { parseArtifactStudioManifest } from './manifest.js';
9
11
  import { hasArtifactStudioManifest, readArtifactStudioSource } from './project.js';
12
+ import { localGitMetadata, parseSourceSpec, resolveArtifactSource, } from './source-resolver.js';
10
13
  import { getAccessToken, loginWithPkce } from './auth.js';
14
+ import { acquireDevLock } from './dev-lock.js';
15
+ import { runWatchLoop } from './watch-loop.js';
16
+ import { hashLinkedSources, parseLinks, rebuildLinkedDep, resolveLinkedDeps, syncLinkedDist, } from './dev-link.js';
17
+ import { lintArtifactSandbox, reportSandboxWarnings } from './sandbox-lint.js';
18
+ import yaml from 'js-yaml';
11
19
  const COMMANDS = [
12
- 'init', 'login', 'logout', 'whoami', 'link', 'env', 'status',
20
+ 'init', 'login', 'logout', 'whoami', 'link', 'env', 'status', 'list', 'show',
13
21
  'validate', 'build', 'plan', 'deploy', 'dev', 'promote', 'pull', 'rollback',
14
22
  ];
15
23
  const USAGE = `usage:
16
- artifact-studio init <dir>
17
- artifact-studio login [--token <token>]
18
- artifact-studio logout
19
- artifact-studio whoami --env <env>
20
- artifact-studio link --env <env> [--project <id>]
21
- artifact-studio env list|use <env>
22
- artifact-studio status [--env <env>]
23
- artifact-studio validate [dir]
24
- artifact-studio build [dir] [--out dist/artifact-bundle.json]
25
- artifact-studio plan [dir] --env <env>
26
- artifact-studio deploy [dir] --env <env>
27
- artifact-studio dev [dir] --env <env> [--once] [--key default]
28
- artifact-studio promote <deployment-id> --env <env>
29
- artifact-studio pull <project-id> --env <env> --out <dir>
30
- artifact-studio rollback <deployment-id> --env <env>`;
24
+ seq-studio artifact init <dir>
25
+ seq-studio artifact whoami --env <env>
26
+ seq-studio artifact link --env <env> [--project <id>]
27
+ seq-studio artifact env list|use <env>
28
+ seq-studio artifact status [--env <env>]
29
+ seq-studio artifact list --env <env>
30
+ seq-studio artifact show <slug-or-id> --env <env>
31
+ seq-studio artifact validate [dir]
32
+ seq-studio artifact build [dir | --repo <ns>/<name> | --git-url <url>] [--ref <ref>] [--out dist/artifact-bundle.json]
33
+ seq-studio artifact plan [dir | --repo <ns>/<name> | --git-url <url>] [--ref <ref>] --env <env>
34
+ seq-studio artifact deploy [dir | --repo <ns>/<name> | --git-url <url>] [--ref <ref>] --env <env> [--skip-unchanged] [--no-create] [--project <id>]
35
+ seq-studio artifact dev [dir] --env <env> [--once] [--key default] [--link <pkgDir>[,<pkgDir>]]
36
+ seq-studio artifact promote <deployment-id> --env <env>
37
+ seq-studio artifact pull <project-id> --env <env> --out <dir>
38
+ seq-studio artifact rollback <deployment-id> --env <env>
39
+
40
+ Source: a local [dir] (default), a platform git-service repo (--repo <ns>/<name>),
41
+ or any git URL (--git-url <url>). --ref picks a branch/tag/commit (default: the
42
+ repo's default branch). The target project comes from the source's
43
+ artifact.bundle.yml project_id.
44
+
45
+ --repo clones over smart-HTTP and REQUIRES a git PAT in ATLAS_GIT_PAT
46
+ (repo:read scope) — create one with \`seq-studio auth pat create --scopes
47
+ repo:read\` or in Atlas → Settings → Tokens. It's the same token you clone the
48
+ repo with. (--env + seqapi login are still needed to resolve the repo and
49
+ upload the deployment.)
50
+
51
+ Authenticate with: seqapi login`;
31
52
  export async function runCli(argv = process.argv.slice(2)) {
32
53
  const parsed = parseArgs(argv);
33
54
  if ('error' in parsed) {
@@ -42,6 +63,8 @@ export async function runCli(argv = process.argv.slice(2)) {
42
63
  case 'link': return linkCommand(parsed);
43
64
  case 'env': return envCommand(parsed);
44
65
  case 'status': return statusCommand(parsed);
66
+ case 'list': return listCommand(parsed);
67
+ case 'show': return showCommand(parsed);
45
68
  case 'validate': return validateCommand(parsed);
46
69
  case 'build': return buildCommand(parsed);
47
70
  case 'plan': return planCommand(parsed);
@@ -92,24 +115,24 @@ async function initCommand(args) {
92
115
  await replaceInFile(join(dir, 'artifact.bundle.yml'), /\{\{slug\}\}/g, slug);
93
116
  await replaceInFile(join(dir, 'artifact.bundle.yml'), /\{\{title\}\}/g, titleize(slug));
94
117
  await replaceInFile(join(dir, 'package.json'), /\{\{slug\}\}/g, slug);
95
- console.log(`[artifact-studio] initialized ${dir}`);
96
- console.log('Next: artifact-studio login && artifact-studio dev --env staging');
118
+ console.log(`[seq-studio] initialized ${dir}`);
119
+ console.log('Next: seqapi login && seq-studio artifact dev --env staging');
97
120
  return 0;
98
121
  }
99
122
  async function loginCommand(args) {
100
123
  const token = typeof args.flags.token === 'string' ? args.flags.token : undefined;
101
124
  if (token) {
102
125
  await writeTokenConfig({ accessToken: token });
103
- console.log('[artifact-studio] saved access token');
126
+ console.log('[seq-studio] saved access token');
104
127
  return 0;
105
128
  }
106
129
  await loginWithPkce();
107
- console.log('[artifact-studio] logged in');
130
+ console.log('[seq-studio] logged in');
108
131
  return 0;
109
132
  }
110
133
  async function logoutCommand() {
111
134
  await writeTokenConfig({});
112
- console.log('[artifact-studio] logged out');
135
+ console.log('[seq-studio] logged out');
113
136
  return 0;
114
137
  }
115
138
  async function whoamiCommand(args) {
@@ -120,8 +143,24 @@ async function whoamiCommand(args) {
120
143
  token,
121
144
  path: '/api/auth/me',
122
145
  }).catch(async () => {
123
- const projects = await getJson({ baseUrl: env.url, token, path: '/api/artifact-studio/projects' });
124
- return { subject: `authenticated (${projects.projects.length} visible project(s))` };
146
+ const projects = await getJson({
147
+ baseUrl: env.url,
148
+ token,
149
+ path: '/api/artifact-studio/projects',
150
+ });
151
+ const slugs = projects.projects
152
+ .map((p) => p.slug)
153
+ .filter((slug) => typeof slug === 'string' && slug.length > 0);
154
+ const preview = slugs.slice(0, 5);
155
+ const more = slugs.length > preview.length
156
+ ? ` (+${slugs.length - preview.length} more — run seq-studio artifact list -e ${env.name})`
157
+ : slugs.length === 0
158
+ ? ` — run seq-studio artifact list -e ${env.name}`
159
+ : '';
160
+ const slugPart = preview.length > 0 ? `: ${preview.join(', ')}` : '';
161
+ return {
162
+ subject: `authenticated (${slugs.length} visible project(s)${slugPart}${more})`,
163
+ };
125
164
  });
126
165
  console.log('user' in data ? (data.user?.email ?? data.user?.sub ?? 'authenticated') : data.subject);
127
166
  return 0;
@@ -159,7 +198,7 @@ async function linkCommand(args) {
159
198
  defaultEnv: env.name,
160
199
  previewKey: 'default',
161
200
  }, dir);
162
- console.log(`[artifact-studio] linked ${project.title} (${project.id})`);
201
+ console.log(`[seq-studio] linked ${project.title} (${project.id})`);
163
202
  return 0;
164
203
  }
165
204
  async function envCommand(args) {
@@ -172,10 +211,10 @@ async function envCommand(args) {
172
211
  const env = resolveEnvironment(value);
173
212
  const config = await readLocalConfig();
174
213
  await writeLocalConfig({ ...config, defaultEnv: env.name });
175
- console.log(`[artifact-studio] default env set to ${env.name}`);
214
+ console.log(`[seq-studio] default env set to ${env.name}`);
176
215
  return 0;
177
216
  }
178
- console.error('artifact-studio env list|use <env>');
217
+ console.error('seq-studio artifact env list|use <env>');
179
218
  return 1;
180
219
  }
181
220
  async function statusCommand(args) {
@@ -194,75 +233,292 @@ async function statusCommand(args) {
194
233
  }
195
234
  return 0;
196
235
  }
236
+ async function listCommand(args) {
237
+ const env = await envFromArgs(args);
238
+ const token = await getRequiredToken(args);
239
+ const data = await getJson({
240
+ baseUrl: env.url,
241
+ token,
242
+ path: '/api/artifact-studio/projects',
243
+ });
244
+ if (data.projects.length === 0) {
245
+ console.log(`[seq-studio] no artifact projects visible on ${env.name}`);
246
+ return 0;
247
+ }
248
+ const sorted = [...data.projects].sort((a, b) => a.slug.localeCompare(b.slug));
249
+ console.log(`[seq-studio] ${sorted.length} project${sorted.length === 1 ? '' : 's'} on ${env.name}:`);
250
+ for (const project of sorted) {
251
+ // Rows can carry activeDeploymentId with the deployment detail nulled
252
+ // (the serializer masks it for reduced-access rows) — label those
253
+ // "deployed" rather than falsely claiming "not deployed".
254
+ const active = project.activeDeployment
255
+ ? project.activeDeployment.version
256
+ : project.activeDeploymentId
257
+ ? 'deployed'
258
+ : 'not deployed';
259
+ const visibility = project.visibility ? ` ${project.visibility}` : '';
260
+ console.log(` ${project.slug} id=${project.id} [${active}]${visibility} ${project.title}`);
261
+ }
262
+ return 0;
263
+ }
264
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
265
+ async function showCommand(args) {
266
+ const ref = args.positional[0];
267
+ if (!ref) {
268
+ console.error('usage: seq-studio artifact show <slug-or-id> --env <env>');
269
+ return 1;
270
+ }
271
+ const env = await envFromArgs(args);
272
+ const token = await getRequiredToken(args);
273
+ // A UUID targets the project directly; anything else resolves by slug (hard
274
+ // error on cross-owner duplicates, same rule as deploy's slug resolution).
275
+ let projectId = ref;
276
+ if (!UUID_RE.test(ref)) {
277
+ const bySlug = await fetchRemoteProject({
278
+ env: env.url,
279
+ token,
280
+ slug: ref,
281
+ disambiguator: 'pass the project id instead: `seq-studio artifact show <id> --env <env>`.',
282
+ });
283
+ if (!bySlug) {
284
+ console.error(`[seq-studio] no project with slug "${ref}" on ${env.name}`);
285
+ return 1;
286
+ }
287
+ projectId = bySlug.id;
288
+ }
289
+ const data = await getJsonOr404({
290
+ baseUrl: env.url,
291
+ token,
292
+ path: `/api/artifact-studio/projects/${projectId}`,
293
+ });
294
+ if (!data) {
295
+ console.error(`[seq-studio] project ${projectId} not found on ${env.name}`);
296
+ return 1;
297
+ }
298
+ const project = data.project;
299
+ console.log(`[seq-studio] project on ${env.name}:`);
300
+ console.log(` id: ${project.id}`);
301
+ console.log(` slug: ${project.slug}`);
302
+ console.log(` title: ${project.title}`);
303
+ if (project.description)
304
+ console.log(` description: ${project.description}`);
305
+ if (project.visibility)
306
+ console.log(` visibility: ${project.visibility}`);
307
+ if (project.appEligible !== undefined)
308
+ console.log(` appEligible: ${project.appEligible ? 'yes' : 'no'}`);
309
+ if (project.createdBy)
310
+ console.log(` createdBy: ${project.createdBy}`);
311
+ if (project.updatedAt)
312
+ console.log(` updated: ${project.updatedAt}`);
313
+ const deployment = project.activeDeployment;
314
+ if (deployment) {
315
+ console.log(` active deployment:`);
316
+ console.log(` version: ${deployment.version}`);
317
+ console.log(` sourceHash: ${deployment.sourceHash}`);
318
+ if (deployment.gitCommit) {
319
+ const dirty = deployment.gitDirty ? ' (dirty)' : '';
320
+ const branch = deployment.gitBranch ? ` on ${deployment.gitBranch}` : '';
321
+ console.log(` git: ${deployment.gitCommit.slice(0, 12)}${branch}${dirty}`);
322
+ }
323
+ if (deployment.deployMessage)
324
+ console.log(` message: ${deployment.deployMessage}`);
325
+ if (deployment.createdAt)
326
+ console.log(` deployedAt: ${deployment.createdAt}`);
327
+ if (deployment.createdBy)
328
+ console.log(` deployedBy: ${deployment.createdBy}`);
329
+ }
330
+ else {
331
+ console.log(` active deployment: none`);
332
+ }
333
+ return 0;
334
+ }
197
335
  async function validateCommand(args) {
198
336
  const dir = resolve(args.positional[0] ?? '.');
199
337
  const source = await readArtifactStudioSource(dir);
200
- console.log(`[artifact-studio] valid ${source.manifest.bundle.name}`);
201
- console.log(`[artifact-studio] files: ${source.files.length}`);
202
- console.log(`[artifact-studio] source: ${source.sourceHash}`);
338
+ console.log(`[seq-studio] valid ${source.manifest.bundle.name}`);
339
+ console.log(`[seq-studio] files: ${source.files.length}`);
340
+ console.log(`[seq-studio] source: ${source.sourceHash}`);
341
+ reportSandboxWarnings(lintArtifactSandbox(source.files));
203
342
  return 0;
204
343
  }
344
+ async function buildPreparedArtifactSource(source) {
345
+ await prepareArtifactBuildRoot(source.dir, { remote: source.remote });
346
+ return buildArtifactStudioProject(source.dir);
347
+ }
205
348
  async function buildCommand(args) {
206
- const dir = resolve(args.positional[0] ?? '.');
207
- const result = await buildArtifactStudioProject(dir);
208
- const out = typeof args.flags.out === 'string' ? resolve(args.flags.out) : resolve(dir, 'dist', 'artifact-bundle.json');
209
- await writeBuildArtifact(result, out);
210
- console.log(`[artifact-studio] built ${result.manifest.bundle.name}`);
211
- console.log(`[artifact-studio] source: ${result.sourceHash}`);
212
- console.log(`[artifact-studio] bundle: ${result.bundleHash}`);
213
- console.log(`[artifact-studio] wrote ${out}`);
214
- return 0;
349
+ const spec = parseSourceSpec(args);
350
+ // build is offline for a local/git-url source; only a git-service source
351
+ // needs an env + token to fetch the tree.
352
+ const remote = spec.kind === 'git-service'
353
+ ? { baseUrl: (await envFromArgs(args)).url, token: await getRequiredToken(args) }
354
+ : {};
355
+ const source = await resolveArtifactSource(spec, remote);
356
+ try {
357
+ const result = await buildPreparedArtifactSource(source);
358
+ // A remote source's dir is a temp dir we tear down below, so default the
359
+ // bundle output next to the cwd rather than into the throwaway tree.
360
+ const defaultOutDir = spec.kind === 'local' ? source.dir : process.cwd();
361
+ const out = typeof args.flags.out === 'string' ? resolve(args.flags.out) : resolve(defaultOutDir, 'dist', 'artifact-bundle.json');
362
+ await writeBuildArtifact(result, out);
363
+ console.log(`[seq-studio] built ${result.manifest.bundle.name}`);
364
+ console.log(`[seq-studio] source: ${result.sourceHash}`);
365
+ console.log(`[seq-studio] bundle: ${result.bundleHash}`);
366
+ console.log(`[seq-studio] wrote ${out}`);
367
+ reportSandboxWarnings(lintArtifactSandbox(result.files));
368
+ return 0;
369
+ }
370
+ finally {
371
+ await source.cleanup();
372
+ }
215
373
  }
216
374
  async function planCommand(args) {
217
- const dir = resolve(args.positional[0] ?? '.');
218
375
  const env = await envFromArgs(args);
219
376
  const token = await getOptionalToken(args);
220
- const result = await buildArtifactStudioProject(dir);
221
- console.log(`[artifact-studio] plan ${result.manifest.artifact.project_id} -> ${env.name}`);
222
- console.log(` source: ${result.sourceHash}`);
223
- console.log(` bundle: ${result.bundleHash}`);
224
- if (!token) {
225
- console.log(' remote: skipped (run artifact-studio login)');
377
+ const spec = parseSourceSpec(args);
378
+ const source = await resolveArtifactSource(spec, { baseUrl: env.url, token });
379
+ try {
380
+ const result = await buildPreparedArtifactSource(source);
381
+ console.log(`[seq-studio] plan ${result.manifest.artifact.project_id} -> ${env.name}`);
382
+ console.log(` source: ${result.sourceHash}`);
383
+ console.log(` bundle: ${result.bundleHash}`);
384
+ if (!token) {
385
+ console.log(' remote: skipped (run seqapi login)');
386
+ return 0;
387
+ }
388
+ const remote = await fetchRemoteProject({ env: env.url, token, slug: result.manifest.artifact.project_id });
389
+ if (!remote)
390
+ console.log(' action: create project and deploy');
391
+ else if (remote.activeDeployment?.sourceHash === result.sourceHash)
392
+ console.log(' action: no-op');
393
+ else
394
+ console.log(` action: deploy over ${remote.activeDeployment?.version ?? 'no active deployment'}`);
226
395
  return 0;
227
396
  }
228
- const remote = await fetchRemoteProject({ env: env.url, token, slug: result.manifest.artifact.project_id });
229
- if (!remote)
230
- console.log(' action: create project and deploy');
231
- else if (remote.activeDeployment?.sourceHash === result.sourceHash)
232
- console.log(' action: no-op');
233
- else
234
- console.log(` action: deploy over ${remote.activeDeployment?.version ?? 'no active deployment'}`);
235
- return 0;
397
+ finally {
398
+ await source.cleanup();
399
+ }
236
400
  }
237
401
  async function deployCommand(args) {
238
- const dir = resolve(args.positional[0] ?? '.');
239
402
  const env = await envFromArgs(args);
240
403
  const token = await getRequiredToken(args);
241
- const result = await buildArtifactStudioProject(dir);
242
- const project = await ensureRemoteProject({ env: env.url, token, result, dir });
243
- const deployment = await uploadDeployment({ env: env.url, token, projectId: project.id, result, channel: 'active', dir });
244
- console.log(`[artifact-studio] deployed ${project.title} ${deployment.version}`);
245
- console.log(`${env.url}/artifact-studio/projects/${project.id}/deployments/${deployment.id}`);
246
- return 0;
404
+ const spec = parseSourceSpec(args);
405
+ const explicitProjectId = typeof args.flags.project === 'string' ? args.flags.project : null;
406
+ const source = await resolveArtifactSource(spec, { baseUrl: env.url, token });
407
+ try {
408
+ // --skip-unchanged: hash the raw source (no build needed) and no-op when
409
+ // the remote active deployment already matches. Lets CI redeploy every
410
+ // artifact on every release without paying a vite build + upload for the
411
+ // unchanged ones. sourceHash covers manifest + all source files, so any
412
+ // real change falls through to a full deploy.
413
+ if (args.flags['skip-unchanged'] === true) {
414
+ const unbuilt = await readArtifactStudioSource(source.dir);
415
+ const slug = unbuilt.manifest.artifact.project_id;
416
+ const remote = explicitProjectId
417
+ ? (await getJsonOr404({ baseUrl: env.url, token, path: `/api/artifact-studio/projects/${explicitProjectId}` }))?.project ?? null
418
+ : await fetchRemoteProject({ env: env.url, token, slug });
419
+ if (remote?.activeDeployment?.sourceHash === unbuilt.sourceHash) {
420
+ console.log(`[seq-studio] ${slug} unchanged on ${env.name} ` +
421
+ `(active ${remote.activeDeployment.version} matches source ${unbuilt.sourceHash.slice(0, 12)}) — skipping deploy`);
422
+ return 0;
423
+ }
424
+ }
425
+ const result = await buildPreparedArtifactSource(source);
426
+ warnIfInlinedManifestDriftsFromBundle(result);
427
+ // Local sources read/write the project link in their working dir; remote
428
+ // sources resolve strictly by manifest project_id (never a stray config.json
429
+ // in the materialized tree), so pass null.
430
+ const project = await ensureRemoteProject({
431
+ env: env.url,
432
+ token,
433
+ result,
434
+ linkConfigDir: spec.kind === 'local' ? source.dir : null,
435
+ allowCreate: args.flags['no-create'] !== true,
436
+ explicitProjectId,
437
+ });
438
+ const deployment = await uploadDeployment({ env: env.url, token, projectId: project.id, result, channel: 'active', provenance: source.provenance });
439
+ console.log(`[seq-studio] deployed ${project.title} ${deployment.version}`);
440
+ console.log(`${env.url}/artifact-studio/projects/${project.id}/deployments/${deployment.id}`);
441
+ return 0;
442
+ }
443
+ finally {
444
+ await source.cleanup();
445
+ }
247
446
  }
248
447
  async function devCommand(args) {
448
+ if (args.flags.repo || args.flags['git-url']) {
449
+ throw new Error('dev watches a local folder; --repo / --git-url are only for build, plan, and deploy.');
450
+ }
249
451
  const dir = resolve(args.positional[0] ?? '.');
250
452
  const env = await envFromArgs(args);
251
- const token = await getRequiredToken(args);
453
+ // Validate auth up front for a fast failure, but resolve a fresh token on
454
+ // every push (below) so a long-running watcher survives access-token expiry
455
+ // instead of dying with "Authentication failed" once the initial token's TTL
456
+ // elapses.
457
+ await getRequiredToken(args);
252
458
  const config = await readLocalConfig(dir);
253
459
  const previewKey = typeof args.flags.key === 'string' ? args.flags.key : config.previewKey ?? 'default';
254
460
  const once = args.flags.once === true;
461
+ const controller = new AbortController();
255
462
  let projectId = null;
256
463
  let lastSourceHash = null;
464
+ let lastDepsHash = null;
257
465
  let buildCount = 0;
466
+ // `--link` lets the watcher rebuild local source packages (e.g. atlas-ui) and
467
+ // refresh the artifact automatically, instead of the rebuild/reinstall/restart
468
+ // dance. Resolved once up front so a bad path fails fast.
469
+ const linkedDeps = resolveLinkedDeps({ links: parseLinks(args.flags.link) });
470
+ for (const dep of linkedDeps) {
471
+ const where = relative(process.cwd(), dep.dir) || '.';
472
+ console.log(`[seq-studio] linked ${dep.name} ← ${where}${dep.inPlace ? ' (in place)' : ''}`);
473
+ }
474
+ // Rebuild any linked package whose source changed and copy its dist into the
475
+ // location the artifact build resolves it from. Returns true when a rebuild
476
+ // happened so the caller forces an artifact rebuild even if its own source is
477
+ // unchanged (the artifact source hash doesn't cover dependency dist).
478
+ async function syncLinkedDeps() {
479
+ if (linkedDeps.length === 0)
480
+ return false;
481
+ const depsHash = await hashLinkedSources(linkedDeps);
482
+ if (depsHash === lastDepsHash)
483
+ return false;
484
+ for (const dep of linkedDeps) {
485
+ console.log(`[seq-studio] rebuilding ${dep.name}…`);
486
+ await rebuildLinkedDep(dep, controller.signal);
487
+ await syncLinkedDist(dep);
488
+ }
489
+ // Record the hash only after every linked dep rebuilt + synced successfully,
490
+ // so a failed rebuild leaves the hash stale and the next tick retries instead
491
+ // of going quiet until the source changes again.
492
+ lastDepsHash = depsHash;
493
+ return true;
494
+ }
258
495
  async function pushPreview() {
259
- const result = await buildArtifactStudioProject(dir);
260
- if (result.sourceHash === lastSourceHash)
496
+ // Rebuild linked source packages first; a dep change forces the artifact
497
+ // rebuild below even when the artifact's own source is untouched.
498
+ const depsChanged = await syncLinkedDeps();
499
+ // Cheap in-process change detection (fs read + hash, no Vite) so we only
500
+ // pay for the expensive build when the source actually changed.
501
+ const { sourceHash } = await readArtifactStudioSource(dir);
502
+ if (!depsChanged && sourceHash === lastSourceHash)
503
+ return;
504
+ // Run the real build in a short-lived child process: repeated in-process
505
+ // Vite builds leak memory that GC can't reclaim and eventually OOM a
506
+ // long-running watch. Isolating each build keeps memory flat. The signal
507
+ // reaps the child if the watcher is shutting down mid-build.
508
+ const result = await buildArtifactStudioProjectIsolated(dir, { signal: controller.signal });
509
+ warnIfInlinedManifestDriftsFromBundle(result);
510
+ // The tree can change between the cheap pre-read above and the build
511
+ // finishing — including reverting back to the last-shipped snapshot. Trust
512
+ // the authoritative build hash and skip the redundant preview push when the
513
+ // source is unchanged — unless a linked dep changed, where the artifact
514
+ // source hash is identical but the bundled dependency code is not.
515
+ if (!depsChanged && result.sourceHash === lastSourceHash)
261
516
  return;
262
- lastSourceHash = result.sourceHash;
263
517
  buildCount += 1;
518
+ // Resolve per push so the token refreshes across a long watch session.
519
+ const token = await getRequiredToken(args);
264
520
  if (!projectId) {
265
- const project = await ensureRemoteProject({ env: env.url, token, result, dir });
521
+ const project = await ensureRemoteProject({ env: env.url, token, result, linkConfigDir: dir });
266
522
  projectId = project.id;
267
523
  }
268
524
  const deployment = await uploadDeployment({
@@ -272,25 +528,90 @@ async function devCommand(args) {
272
528
  result,
273
529
  channel: 'preview',
274
530
  previewKey,
275
- dir,
531
+ provenance: localGitMetadata(dir),
276
532
  message: `Preview push ${buildCount}`,
277
533
  });
278
- console.log(`[artifact-studio] preview ${deployment.version}`);
534
+ // Mark this source as shipped only after the upload succeeds, so a failed
535
+ // build/upload leaves lastSourceHash stale and the next tick retries.
536
+ lastSourceHash = result.sourceHash;
537
+ console.log(`[seq-studio] preview ${deployment.version}`);
279
538
  console.log(`${env.url}/artifact-studio/projects/${projectId}/preview?key=${encodeURIComponent(previewKey)}`);
280
539
  }
281
- await pushPreview();
282
- if (once)
540
+ // Best-effort liveness/build status to the dev server, so the preview page can
541
+ // show "live / watcher offline / build failed" instead of a fake spinner.
542
+ // Fire-and-forget: a heartbeat failure must never disrupt the watch loop.
543
+ async function sendHeartbeat(status) {
544
+ if (!projectId)
545
+ return;
546
+ try {
547
+ const token = await getRequiredToken(args);
548
+ await postJson({
549
+ baseUrl: env.url,
550
+ token,
551
+ path: `/api/artifact-studio/projects/${projectId}/preview-status?previewKey=${encodeURIComponent(previewKey)}`,
552
+ body: { buildOk: status.buildOk, error: status.error },
553
+ });
554
+ }
555
+ catch {
556
+ // ignore — dev telemetry only
557
+ }
558
+ }
559
+ // Run one build/push and report status. Build failure is captured (not thrown)
560
+ // so the heartbeat still goes out as buildOk=false with the error message.
561
+ async function tickWithHeartbeat() {
562
+ let buildOk = true;
563
+ let buildError = null;
564
+ try {
565
+ await pushPreview();
566
+ }
567
+ catch (error) {
568
+ buildOk = false;
569
+ buildError = error instanceof Error ? error.message : String(error);
570
+ console.error(buildError);
571
+ }
572
+ await sendHeartbeat({ buildOk, error: buildError });
573
+ }
574
+ // Surface sandbox traps once at startup (not per rebuild — that would spam the
575
+ // watch log). Re-run `dev`/`build`/`validate` to re-check after edits.
576
+ try {
577
+ const initial = await readArtifactStudioSource(dir);
578
+ reportSandboxWarnings(lintArtifactSandbox(initial.files));
579
+ }
580
+ catch {
581
+ // A malformed source surfaces its real error from the build below.
582
+ }
583
+ // One-shot push: no watcher, no lock.
584
+ if (once) {
585
+ await pushPreview();
283
586
  return 0;
284
- console.log('[artifact-studio] watching for changes. Press Ctrl+C to stop.');
285
- await new Promise((resolvePromise) => {
286
- const interval = setInterval(() => {
287
- pushPreview().catch((error) => console.error(error instanceof Error ? error.message : error));
288
- }, 1500);
289
- process.on('SIGINT', () => {
290
- clearInterval(interval);
291
- resolvePromise();
587
+ }
588
+ // Single-instance guard: stop any watcher already running for this project and
589
+ // take over, so re-running `dev` never orphans a watcher that keeps racing on
590
+ // the shared token file.
591
+ const releaseLock = await acquireDevLock({ dir, previewKey });
592
+ const shutdown = (exitSignal) => {
593
+ controller.abort();
594
+ releaseLock();
595
+ if (exitSignal)
596
+ process.exit(0);
597
+ };
598
+ process.once('SIGINT', () => shutdown('SIGINT'));
599
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
600
+ process.once('exit', () => releaseLock());
601
+ try {
602
+ await pushPreview();
603
+ await sendHeartbeat({ buildOk: true, error: null });
604
+ console.log('[seq-studio] watching for changes. Press Ctrl+C to stop.');
605
+ await runWatchLoop({
606
+ tick: tickWithHeartbeat,
607
+ intervalMs: 1500,
608
+ signal: controller.signal,
609
+ onError: (error) => console.error(error instanceof Error ? error.message : error),
292
610
  });
293
- });
611
+ }
612
+ finally {
613
+ releaseLock();
614
+ }
294
615
  return 0;
295
616
  }
296
617
  async function promoteCommand(args) {
@@ -301,14 +622,14 @@ async function promoteCommand(args) {
301
622
  const token = await getRequiredToken(args);
302
623
  const config = await readLocalConfig();
303
624
  if (!config.projectId)
304
- throw new Error('Project is not linked. Run artifact-studio link.');
625
+ throw new Error('Project is not linked. Run seq-studio artifact link.');
305
626
  await postJson({
306
627
  baseUrl: env.url,
307
628
  token,
308
629
  path: `/api/artifact-studio/projects/${config.projectId}/deployments/${deploymentId}/promote`,
309
630
  body: {},
310
631
  });
311
- console.log(`[artifact-studio] promoted ${deploymentId}`);
632
+ console.log(`[seq-studio] promoted ${deploymentId}`);
312
633
  return 0;
313
634
  }
314
635
  async function pullCommand(args) {
@@ -340,7 +661,7 @@ async function pullCommand(args) {
340
661
  await mkdir(dirname(target), { recursive: true });
341
662
  await writeFile(target, file.content, 'utf8');
342
663
  }
343
- console.log(`[artifact-studio] pulled ${projectId} to ${out}`);
664
+ console.log(`[seq-studio] pulled ${projectId} to ${out}`);
344
665
  return 0;
345
666
  }
346
667
  async function rollbackCommand(args) {
@@ -351,29 +672,45 @@ async function rollbackCommand(args) {
351
672
  const token = await getRequiredToken(args);
352
673
  const config = await readLocalConfig();
353
674
  if (!config.projectId)
354
- throw new Error('Project is not linked. Run artifact-studio link.');
675
+ throw new Error('Project is not linked. Run seq-studio artifact link.');
355
676
  await postJson({
356
677
  baseUrl: env.url,
357
678
  token,
358
679
  path: `/api/artifact-studio/projects/${config.projectId}/deployments/${deploymentId}/rollback`,
359
680
  body: {},
360
681
  });
361
- console.log(`[artifact-studio] rolled back to ${deploymentId}`);
682
+ console.log(`[seq-studio] rolled back to ${deploymentId}`);
362
683
  return 0;
363
684
  }
364
- async function ensureRemoteProject({ env, token, result, dir }) {
365
- const config = await readLocalConfig(dir);
685
+ async function ensureRemoteProject({ env, token, result, linkConfigDir, allowCreate = true, explicitProjectId = null }) {
686
+ if (explicitProjectId) {
687
+ const data = await getJsonOr404({ baseUrl: env, token, path: `/api/artifact-studio/projects/${explicitProjectId}` });
688
+ if (!data)
689
+ throw new Error(`--project ${explicitProjectId}: project not found on this environment`);
690
+ if (data.project.slug !== result.manifest.artifact.project_id) {
691
+ throw new Error(`--project ${explicitProjectId} resolves to slug "${data.project.slug}" but this source's ` +
692
+ `artifact.bundle.yml declares project_id "${result.manifest.artifact.project_id}" — refusing to deploy over a different project`);
693
+ }
694
+ return data.project;
695
+ }
696
+ const config = linkConfigDir ? await readLocalConfig(linkConfigDir) : {};
366
697
  if (config.projectId) {
367
698
  const data = await getJsonOr404({ baseUrl: env, token, path: `/api/artifact-studio/projects/${config.projectId}` });
368
699
  if (data)
369
700
  return data.project;
370
- console.warn(`[artifact-studio] cached projectId ${config.projectId} not found on ${env} — relinking by slug`);
701
+ console.warn(`[seq-studio] cached projectId ${config.projectId} not found on ${env} — relinking by slug`);
371
702
  }
372
703
  const existing = await fetchRemoteProject({ env, token, slug: result.manifest.artifact.project_id });
373
704
  if (existing) {
374
- await writeLocalConfig({ ...config, projectId: existing.id, projectSlug: existing.slug }, dir);
705
+ if (linkConfigDir)
706
+ await writeLocalConfig({ ...config, projectId: existing.id, projectSlug: existing.slug }, linkConfigDir);
375
707
  return existing;
376
708
  }
709
+ if (!allowCreate) {
710
+ throw new Error(`project "${result.manifest.artifact.project_id}" does not exist on ${env} and --no-create is set. ` +
711
+ `First-time provisioning is a human step: run \`seq-studio artifact deploy . -e <env>\` once with your ` +
712
+ `own login (seqapi login) to create the project, then the CI sweep keeps it updated.`);
713
+ }
377
714
  const created = await postJson({
378
715
  baseUrl: env,
379
716
  token,
@@ -384,13 +721,21 @@ async function ensureRemoteProject({ env, token, result, dir }) {
384
721
  description: result.manifest.artifact.description ?? null,
385
722
  },
386
723
  });
387
- await writeLocalConfig({ ...config, projectId: created.project.id, projectSlug: created.project.slug }, dir);
724
+ if (linkConfigDir)
725
+ await writeLocalConfig({ ...config, projectId: created.project.id, projectSlug: created.project.slug }, linkConfigDir);
388
726
  return created.project;
389
727
  }
390
- async function uploadDeployment({ env, token, projectId, result, channel, previewKey, dir, message, }) {
728
+ async function uploadDeployment({ env, token, projectId, result, channel, previewKey, provenance, message, }) {
729
+ // Active deploys omit `version`: the server derives it from its own
730
+ // sourceHash and de-dupes against the (projectId, version) unique
731
+ // constraint. Pinning it client-side is how a stale CLI-side hash turned
732
+ // redeploys into opaque 500s (unique violation on a version string minted
733
+ // from a hash the server no longer agrees with). Previews keep a
734
+ // timestamped version so successive pushes of identical source stay
735
+ // distinguishable.
391
736
  const version = channel === 'preview'
392
737
  ? `preview-${Date.now().toString(36)}-${result.sourceHash.slice(0, 8)}`
393
- : result.sourceHash.slice(0, 12);
738
+ : undefined;
394
739
  const data = await postJson({
395
740
  baseUrl: env,
396
741
  token,
@@ -400,32 +745,59 @@ async function uploadDeployment({ env, token, projectId, result, channel, previe
400
745
  files: result.files,
401
746
  bundle: result.bundle,
402
747
  sourceMap: result.sourceMap,
403
- version,
748
+ ...(version ? { version } : {}),
404
749
  channel,
405
750
  previewKey,
406
751
  deployMessage: message ?? null,
407
- ...gitMetadata(dir),
752
+ ...provenance,
408
753
  },
409
754
  });
410
755
  return data.deployment;
411
756
  }
412
- async function fetchRemoteProject({ env, token, slug }) {
757
+ async function fetchRemoteProject({ env, token, slug, disambiguator }) {
413
758
  const data = await getJson({ baseUrl: env, token, path: '/api/artifact-studio/projects' });
414
- return data.projects.find((project) => project.slug === slug) ?? null;
759
+ const matches = data.projects.filter((project) => project.slug === slug);
760
+ // Slugs are only unique per creator — a caller who can see several users'
761
+ // projects (notably the CI service account, which sees everything) can get
762
+ // multiple hits. Picking "whichever sorts first" would deploy over an
763
+ // arbitrary project, so ambiguity is a hard error with the disambiguator.
764
+ if (matches.length > 1) {
765
+ throw new Error(`${matches.length} projects with slug "${slug}" exist on this environment ` +
766
+ `(ids: ${matches.map((project) => project.id).join(', ')}) — ` +
767
+ (disambiguator ??
768
+ 'target one explicitly with --project <id> (deploy) or `seq-studio artifact link . --project <id>`.'));
769
+ }
770
+ return matches[0] ?? null;
415
771
  }
416
772
  async function envFromArgs(args) {
417
773
  const config = await readLocalConfig().catch(() => ({ defaultEnv: undefined }));
418
774
  return resolveEnvironment(typeof args.flags.env === 'string' ? args.flags.env : undefined, config.defaultEnv);
419
775
  }
776
+ /**
777
+ * Optional refreshing token provider, injected by an embedder (e.g. seq-studio)
778
+ * that owns the shared seqapi token file and can mint a fresh access token on
779
+ * demand. Consulted before the static ARTIFACT_STUDIO_TOKEN env var so a
780
+ * long-running `dev` watcher keeps deploying past the initial token's TTL —
781
+ * the env var is captured once at process start and never refreshes.
782
+ */
783
+ let tokenProvider = null;
784
+ export function setTokenProvider(provider) {
785
+ tokenProvider = provider;
786
+ }
420
787
  async function getRequiredToken(args) {
421
788
  const token = await getOptionalToken(args);
422
789
  if (!token)
423
- throw new Error('Missing token. Run artifact-studio login or pass --token.');
790
+ throw new Error('Missing token. Run seqapi login or pass --token.');
424
791
  return token;
425
792
  }
426
793
  async function getOptionalToken(args) {
427
794
  if (typeof args.flags.token === 'string')
428
795
  return args.flags.token;
796
+ if (tokenProvider) {
797
+ const provided = await tokenProvider();
798
+ if (provided)
799
+ return provided;
800
+ }
429
801
  if (process.env.ARTIFACT_STUDIO_TOKEN)
430
802
  return process.env.ARTIFACT_STUDIO_TOKEN;
431
803
  return getAccessToken();
@@ -434,31 +806,27 @@ async function replaceInFile(path, pattern, value) {
434
806
  const content = await import('node:fs/promises').then((fs) => fs.readFile(path, 'utf8'));
435
807
  await writeFile(path, content.replace(pattern, value), 'utf8');
436
808
  }
437
- function gitMetadata(dir) {
438
- const gitOpts = {
439
- cwd: dir,
440
- encoding: 'utf8',
441
- stdio: ['ignore', 'pipe', 'ignore'],
442
- };
443
- try {
444
- const gitCommit = String(execFileSync('git', ['rev-parse', 'HEAD'], gitOpts)).trim();
445
- const gitBranch = String(execFileSync('git', ['branch', '--show-current'], gitOpts)).trim();
446
- const gitDirty = String(execFileSync('git', ['status', '--porcelain'], gitOpts)).trim().length > 0;
447
- return { gitCommit, gitBranch, gitDirty };
448
- }
449
- catch {
450
- return { gitCommit: null, gitBranch: null, gitDirty: null };
451
- }
452
- }
453
809
  function slugify(value) {
454
810
  return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'my-artifact';
455
811
  }
456
812
  function titleize(slug) {
457
813
  return slug.split('-').map((part) => part ? part[0].toUpperCase() + part.slice(1) : '').join(' ');
458
814
  }
459
- // Phase 2 of the seq-lattice rollout (see
460
- // shared/services/lattice-cli/README.md): this module is now a pure
461
- // library export. The CLI entry point lives in `bin.ts` and is what the
462
- // `artifact-studio` binary points at. seq-lattice imports `runCli` from
463
- // `@sequenceholdings/artifact-studio/cli` and re-invokes it after injecting a
815
+ function warnIfInlinedManifestDriftsFromBundle(result) {
816
+ const bundleFile = result.files.find((file) => file.path === 'artifact.bundle.yml');
817
+ if (!bundleFile)
818
+ return;
819
+ try {
820
+ const fromBundle = parseArtifactStudioManifest(yaml.load(bundleFile.content));
821
+ if (fromBundle.sidebarBadge && !result.manifest.sidebarBadge) {
822
+ console.warn('[seq-studio] warning: artifact.bundle.yml declares sidebarBadge but the deploy payload omits it — upgrade @sequenceholdings/artifact-studio / seq-studio');
823
+ }
824
+ }
825
+ catch {
826
+ // validate/build already surfaced parse errors
827
+ }
828
+ }
829
+ // This module is a pure library export. `seq-studio artifact` imports
830
+ // `runCli` from `@sequenceholdings/artifact-studio/cli` (see
831
+ // shared/services/studio-cli/README.md) and invokes it after injecting a
464
832
  // shared base URL + Bearer token.