@sequenceholdings/artifact-studio 0.9.1 → 0.10.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.
Files changed (54) hide show
  1. package/README.md +219 -0
  2. package/VERSION-PIN.md +15 -0
  3. package/dist/api.d.ts +5 -0
  4. package/dist/api.js +7 -0
  5. package/dist/artifact-env-bindings.d.ts +1 -1
  6. package/dist/artifact-env-bindings.js +5 -4
  7. package/dist/build-input-guards.d.ts +8 -0
  8. package/dist/build-input-guards.js +178 -0
  9. package/dist/build.js +14 -5
  10. package/dist/cli-content-readers.d.ts +8 -0
  11. package/dist/cli-content-readers.js +28 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +38 -6
  14. package/dist/{env-binding-scan.d.ts → env-binding-manifest.d.ts} +1 -4
  15. package/dist/env-binding-manifest.js +56 -0
  16. package/dist/env-bindings.js +2 -34
  17. package/dist/git-service-client.d.ts +11 -0
  18. package/dist/git-service-client.js +10 -0
  19. package/dist/manifest-next.d.ts +6 -0
  20. package/dist/manifest-next.js +3 -0
  21. package/dist/manifest.d.ts +4 -0
  22. package/dist/manifest.js +1 -0
  23. package/dist/next/bridge.d.ts +15 -1
  24. package/dist/next/bridge.js +18 -1
  25. package/dist/next-source-includes.d.ts +50 -0
  26. package/dist/next-source-includes.js +68 -0
  27. package/dist/prepare-build.d.ts +10 -2
  28. package/dist/prepare-build.js +33 -3
  29. package/dist/repository-identity.d.ts +31 -0
  30. package/dist/repository-identity.js +41 -0
  31. package/dist/runtime-contracts.d.ts +1 -4
  32. package/dist/runtime-contracts.js +2 -15
  33. package/dist/server/api-client.d.ts +1 -0
  34. package/dist/server/api-client.js +13 -7
  35. package/dist/server/group-directory.d.ts +90 -0
  36. package/dist/server/group-directory.js +17 -0
  37. package/dist/server/groups.d.ts +2 -1
  38. package/dist/server/groups.js +2 -0
  39. package/dist/server/human-completion.d.ts +44 -0
  40. package/dist/server/human-completion.js +38 -0
  41. package/dist/server/processes.d.ts +19 -0
  42. package/dist/server/processes.js +11 -0
  43. package/dist/server/task-assignments.d.ts +81 -0
  44. package/dist/server/task-assignments.js +26 -0
  45. package/dist/server.d.ts +4 -0
  46. package/dist/source-boundary.d.ts +27 -6
  47. package/dist/source-boundary.js +44 -30
  48. package/dist/source-exclusions.d.ts +4 -0
  49. package/dist/source-exclusions.js +16 -0
  50. package/dist/source-resolver.js +2 -1
  51. package/dist/templates/react-vite/package.json +1 -1
  52. package/package.json +6 -2
  53. package/templates/react-vite/package.json +1 -1
  54. package/dist/env-binding-scan.js +0 -179
@@ -1,15 +1,46 @@
1
1
  import { realpathSync } from 'node:fs';
2
- import { isAbsolute, relative, resolve, sep } from 'node:path';
3
- /**
4
- * Artifact source must be self-contained: allowing a relative import to escape
5
- * the build root would let uncommitted files elsewhere in a containing
6
- * checkout enter the official bundle without being covered by its dirty bit.
7
- * Published dependencies and the CLI-pinned platform peers are intentionally
8
- * allowed because they are external to the artifact by design.
9
- */
10
- export function rejectOutsideSourcePlugin({ root, trustedExternalRoots, }) {
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ export function createSourceBoundary({ root, trustedExternalRoots }) {
11
4
  const rootResolved = realpathSync(resolve(root));
12
5
  const trustedRoots = trustedExternalRoots.map((trustedRoot) => realpathSync(resolve(trustedRoot)));
6
+ const isTrusted = (filePath) => isTrustedExternalPath(physicalPath(filePath), trustedRoots);
7
+ return {
8
+ root: rootResolved,
9
+ isTrusted,
10
+ assertBuildInput({ filePath, importer, bareSpecifier, reference }) {
11
+ const realFilePath = physicalPath(filePath);
12
+ if (isPathWithin(rootResolved, realFilePath))
13
+ return;
14
+ const importerPath = importer?.split('?')[0];
15
+ const importerIsTrustedExternal = importerPath != null &&
16
+ (isNodeModulesPath(importerPath) || (isAbsolute(importerPath) && isTrusted(importerPath)));
17
+ if (isNodeModulesPath(filePath) && (bareSpecifier || importerIsTrustedExternal))
18
+ return;
19
+ if (isTrustedExternalPath(realFilePath, trustedRoots))
20
+ return;
21
+ const from = importerPath ? ` (referenced from ${relative(rootResolved, importerPath)})` : '';
22
+ throw new Error(`Artifact ${reference} "${filePath}" resolves outside artifact source directory ` +
23
+ `"${rootResolved}"${from}. Keep relative build inputs inside the artifact ` +
24
+ 'source directory or publish shared code as a dependency.');
25
+ },
26
+ };
27
+ }
28
+ /**
29
+ * The realpath of `filePath`, or of its deepest existing ancestor joined with
30
+ * the rest, so a reference to a file that does not exist yet is still compared
31
+ * physically.
32
+ */
33
+ function physicalPath(filePath) {
34
+ try {
35
+ return realpathSync(filePath);
36
+ }
37
+ catch {
38
+ const parent = dirname(filePath);
39
+ return parent === filePath ? filePath : join(physicalPath(parent), basename(filePath));
40
+ }
41
+ }
42
+ /** Reject module imports that resolve outside the source boundary. */
43
+ export function rejectOutsideSourcePlugin(boundary) {
13
44
  return {
14
45
  name: 'reject-artifact-outside-source',
15
46
  enforce: 'pre',
@@ -21,26 +52,9 @@ export function rejectOutsideSourcePlugin({ root, trustedExternalRoots, }) {
21
52
  if (!isAbsolute(filePath))
22
53
  return resolved;
23
54
  // Vite may return a physical path for a root reached through /tmp or
24
- // another symlink. Check physical paths on both sides of the boundary;
25
- // this also prevents an in-root symlink from admitting an outside file.
26
- const realFilePath = realpathSync(filePath);
27
- if (isPathWithin(rootResolved, realFilePath))
28
- return resolved;
29
- const importerPath = importer?.split('?')[0];
30
- const importerIsTrustedExternal = importerPath != null &&
31
- (isNodeModulesPath(importerPath) ||
32
- (isAbsolute(importerPath) && isTrustedExternalPath(realpathSync(importerPath), trustedRoots)));
33
- if (isNodeModulesPath(filePath) && (isBareImport(source) || importerIsTrustedExternal)) {
34
- return resolved;
35
- }
36
- if (isTrustedExternalPath(realFilePath, trustedRoots))
37
- return resolved;
38
- const from = importer
39
- ? ` (imported from ${relative(rootResolved, importer.split('?')[0])})`
40
- : '';
41
- throw new Error(`Artifact import "${filePath}" resolves outside artifact source directory ` +
42
- `"${rootResolved}"${from}. Keep relative build inputs inside the artifact ` +
43
- 'source directory or publish shared code as a dependency.');
55
+ // another symlink; the boundary compares physical paths on both sides.
56
+ boundary.assertBuildInput({ filePath, importer, bareSpecifier: isBareImport(source), reference: 'import' });
57
+ return resolved;
44
58
  },
45
59
  };
46
60
  }
@@ -62,6 +76,6 @@ function isTrustedExternalPath(filePath, trustedRoots) {
62
76
  export function isNodeModulesPath(filePath) {
63
77
  return filePath.replaceAll('\\', '/').includes('/node_modules/');
64
78
  }
65
- function isBareImport(source) {
79
+ export function isBareImport(source) {
66
80
  return !source.startsWith('.') && !isAbsolute(source);
67
81
  }
@@ -0,0 +1,4 @@
1
+ /** Shared upload exclusion policy. Content scanning and filesystem containment remain separate checks. */
2
+ export declare function isExcludedArtifactSourcePath({ path }: {
3
+ path: string;
4
+ }): boolean;
@@ -0,0 +1,16 @@
1
+ /** Leaf module (no imports) so manifest and source-include validation can share it without a cycle. */
2
+ const excludedSourceSegments = new Set([
3
+ '.git', 'node_modules', '.next', '.npmrc', '.netrc', '.aws', '.ssh', '.config',
4
+ 'dist', '.artifact-studio', '.git-credentials', '.pypirc', '.azure', '.gcloud', '.doppler',
5
+ '.turbo', 'coverage', '.docker', '.seq-next-output.json',
6
+ ]);
7
+ /** Shared upload exclusion policy. Content scanning and filesystem containment remain separate checks. */
8
+ export function isExcludedArtifactSourcePath({ path }) {
9
+ return path.split('/').some((segment) => {
10
+ const normalized = segment.toLowerCase();
11
+ return excludedSourceSegments.has(normalized)
12
+ || normalized.startsWith('.env') || normalized.startsWith('.yarnrc')
13
+ || /(?:^|[-_.])(?:credentials?|service[-_]?account)(?:[-_.].*)?\.json$/.test(normalized)
14
+ || /\.(pem|key|p12|pfx|jks)$/.test(normalized);
15
+ });
16
+ }
@@ -4,7 +4,7 @@ import { mkdtemp, realpath, rm, stat } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
6
6
  import { createScrubbedChildEnv } from './child-environment.js';
7
- import { materializeRepo, resolveCommitSha, resolveRepo } from './git-service-client.js';
7
+ import { checkRepositoryDeploymentIdentity, materializeRepo, resolveCommitSha, resolveRepo } from './git-service-client.js';
8
8
  import { gitServiceCloneUrl, runGitClone } from './git-clone.js';
9
9
  /** True for sources that must be fetched from a remote (git-service / git-url). */
10
10
  export function isRemoteSpec(spec) {
@@ -126,6 +126,7 @@ export async function resolveArtifactSource(spec, opts = {}) {
126
126
  const repo = await resolveRepo({ baseUrl: opts.baseUrl, token: opts.token, namespace: spec.namespace, name: spec.name });
127
127
  const ref = spec.ref ?? repo.defaultBranch;
128
128
  const sha = await resolveCommitSha({ baseUrl: opts.baseUrl, token: opts.token, repoId: repo.id, ref });
129
+ await checkRepositoryDeploymentIdentity({ baseUrl: opts.baseUrl, token: opts.token, repoId: repo.id, commit: sha });
129
130
  // CI/system path: the trusted M2M service account can't own a PAT (PAT
130
131
  // issuance is gated on a human Auth0 user row), so it keeps the JSON
131
132
  // materialize path it has always used, authenticated by its M2M token.
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {},
12
12
  "devDependencies": {
13
- "@sequenceholdings/artifact-studio": "^0.9.0",
13
+ "@sequenceholdings/artifact-studio": "^0.10.0",
14
14
  "@sequenceholdings/atlas-ui": "^3.1.1",
15
15
  "@tailwindcss/postcss": "^4.2.2",
16
16
  "@tailwindcss/vite": "^4.2.2",
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "publishConfig": {
10
10
  "access": "public"
11
11
  },
12
- "version": "0.9.1",
12
+ "version": "0.10.0",
13
13
  "description": "SDK types and CLI library for building Artifact Studio apps. Driven via `seq-studio artifact <sub>` (@sequenceholdings/studio-cli), which runs the `./cli` runCli export.",
14
14
  "type": "module",
15
15
  "exports": {
@@ -33,6 +33,10 @@
33
33
  "types": "./dist/git-service-client.d.ts",
34
34
  "default": "./dist/git-service-client.js"
35
35
  },
36
+ "./repository-identity": {
37
+ "types": "./dist/repository-identity.d.ts",
38
+ "default": "./dist/repository-identity.js"
39
+ },
36
40
  "./git-clone": {
37
41
  "types": "./dist/git-clone.d.ts",
38
42
  "default": "./dist/git-clone.js"
@@ -169,7 +173,7 @@
169
173
  "parse5": "7.3.0",
170
174
  "@sequenceholdings/lattice-form-renderer": "0.2.0",
171
175
  "@sequenceholdings/storage": "0.1.2",
172
- "@sequenceholdings/orm": "2.6.2"
176
+ "@sequenceholdings/orm": "2.7.0"
173
177
  },
174
178
  "devDependencies": {
175
179
  "@types/js-yaml": "^4.0.9",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {},
12
12
  "devDependencies": {
13
- "@sequenceholdings/artifact-studio": "^0.9.0",
13
+ "@sequenceholdings/artifact-studio": "^0.10.0",
14
14
  "@sequenceholdings/atlas-ui": "^3.1.1",
15
15
  "@tailwindcss/postcss": "^4.2.2",
16
16
  "@tailwindcss/vite": "^4.2.2",
@@ -1,179 +0,0 @@
1
- import yaml from 'js-yaml';
2
- const UUID_SOURCE = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
3
- const SCANNED_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json', '.yml', '.yaml']);
4
- export function originsDeclaredInFiles(files) {
5
- const origins = [];
6
- for (const file of files) {
7
- if (file.encoding === 'base64' || basename(file.path) !== 'artifact.bundle.yml')
8
- continue;
9
- for (const [name, url] of Object.entries(declaredTargets(file.content))) {
10
- try {
11
- origins.push({ name, origin: new URL(url).origin.toLowerCase() });
12
- }
13
- catch {
14
- // A target that is not a URL is reported when the selected environment is checked.
15
- }
16
- }
17
- }
18
- return origins;
19
- }
20
- export function declaredTargets(content) {
21
- let parsed;
22
- try {
23
- parsed = yaml.load(content);
24
- }
25
- catch {
26
- return {};
27
- }
28
- if (!parsed || typeof parsed !== 'object' || !('targets' in parsed))
29
- return {};
30
- const targets = parsed.targets;
31
- if (!targets || typeof targets !== 'object')
32
- return {};
33
- const urls = {};
34
- for (const [name, value] of Object.entries(targets)) {
35
- if (value && typeof value === 'object' && 'url' in value && typeof value.url === 'string')
36
- urls[name] = value.url;
37
- }
38
- return urls;
39
- }
40
- export function contentContainsOrigin(content, origin, skippedLines) {
41
- const haystack = content.toLowerCase();
42
- const needle = origin.toLowerCase();
43
- let from = 0;
44
- while (from < haystack.length) {
45
- const at = haystack.indexOf(needle, from);
46
- if (at < 0)
47
- return false;
48
- const next = haystack[at + needle.length];
49
- const line = haystack.slice(0, at).split('\n').length - 1;
50
- if (!skippedLines.has(line) && (next === undefined || !/[a-z0-9.-]/.test(next)))
51
- return true;
52
- from = at + needle.length;
53
- }
54
- return false;
55
- }
56
- /** Manifest text with the root `targets` map removed, so its URLs are declarations. */
57
- export function contentOutsideTargets(content) {
58
- let parsed;
59
- try {
60
- parsed = yaml.load(content);
61
- }
62
- catch {
63
- return content;
64
- }
65
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !('targets' in parsed))
66
- return content;
67
- const rest = {};
68
- for (const [key, value] of Object.entries(parsed)) {
69
- if (key !== 'targets')
70
- rest[key] = value;
71
- }
72
- return yaml.dump(rest, { lineWidth: -1 });
73
- }
74
- export function collectWorkspaceSlotIds(files) {
75
- const ids = new Set();
76
- for (const file of files) {
77
- if (!scannable(file))
78
- continue;
79
- for (const workspaceId of workspaceSlotIds(file))
80
- ids.add(workspaceId);
81
- }
82
- return [...ids];
83
- }
84
- export function workspaceSlotIds(file) {
85
- const ids = new Set();
86
- if (file.path.endsWith('.yml') || file.path.endsWith('.yaml'))
87
- collectYamlWorkspaceIds(file.content, ids);
88
- const marker = /workspace/gi;
89
- let match;
90
- while ((match = marker.exec(file.content))) {
91
- const region = workspaceExpression(file.content, match.index);
92
- for (const id of region.matchAll(new RegExp(UUID_SOURCE, 'gi')))
93
- ids.add(id[0].toLowerCase());
94
- }
95
- return [...ids];
96
- }
97
- function workspaceExpression(content, start) {
98
- let depth = 0;
99
- let quote;
100
- const limit = Math.min(content.length, start + 500);
101
- for (let index = start; index < limit; index += 1) {
102
- const char = content[index] ?? '';
103
- const step = stepExpression({ char, depth, quote });
104
- depth = step.depth;
105
- quote = step.quote;
106
- if (step.skip) {
107
- index += 1;
108
- continue;
109
- }
110
- if (step.done)
111
- return content.slice(start, index);
112
- if (char === '\n' && depth === 0 && expressionEnds(content.slice(index + 1))) {
113
- return content.slice(start, index);
114
- }
115
- }
116
- return content.slice(start, limit);
117
- }
118
- function stepExpression({ char, depth, quote }) {
119
- if (quote) {
120
- if (char === '\\')
121
- return { depth, quote, skip: true };
122
- return { depth, quote: char === quote ? undefined : quote };
123
- }
124
- if (char === '"' || char === "'" || char === '`')
125
- return { depth, quote: char };
126
- if (char === '(' || char === '[' || char === '{')
127
- return { depth: depth + 1, quote };
128
- if (char === ')' || char === ']' || char === '}')
129
- return { depth: Math.max(0, depth - 1), quote };
130
- if (char === ';' && depth === 0)
131
- return { depth, quote, done: true };
132
- return { depth, quote };
133
- }
134
- function expressionEnds(rest) {
135
- const next = rest.split('\n')[0] ?? '';
136
- return next.trim() === '' || (!/^[ \t]/.test(next) && !/^[)\]},.]/.test(next.trimStart()));
137
- }
138
- export function scannable(file) {
139
- if (file.encoding === 'base64')
140
- return false;
141
- const name = basename(file.path);
142
- if (name === 'pnpm-lock.yaml' || name === 'package-lock.json' || name === 'yarn.lock' || name.endsWith('.md'))
143
- return false;
144
- if (name === '.env')
145
- return true;
146
- const extension = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
147
- return SCANNED_EXTENSIONS.has(extension);
148
- }
149
- export function basename(path) {
150
- const parts = path.split('/');
151
- return parts[parts.length - 1] ?? path;
152
- }
153
- function collectYamlWorkspaceIds(content, ids) {
154
- let parsed;
155
- try {
156
- parsed = yaml.load(content);
157
- }
158
- catch {
159
- return;
160
- }
161
- walkYaml(parsed, null, ids);
162
- }
163
- function walkYaml(node, parentKey, ids) {
164
- if (!node || typeof node !== 'object')
165
- return;
166
- if (Array.isArray(node)) {
167
- for (const item of node)
168
- walkYaml(item, parentKey, ids);
169
- return;
170
- }
171
- for (const [key, value] of Object.entries(node)) {
172
- if (parentKey === 'storage' && new RegExp(`^${UUID_SOURCE}$`, 'i').test(key))
173
- ids.add(key.toLowerCase());
174
- if (/workspace/i.test(key) && typeof value === 'string' && new RegExp(`^${UUID_SOURCE}$`, 'i').test(value)) {
175
- ids.add(value.toLowerCase());
176
- }
177
- walkYaml(value, key, ids);
178
- }
179
- }