@haystackeditor/cli 0.25.0 → 0.25.1
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/capture/adapters/django.js +7 -0
- package/dist/capture/adapters/index.js +10 -0
- package/dist/capture/adapters/next-build.js +113 -0
- package/dist/capture/adapters/next.js +272 -105
- package/dist/capture/adapters/nuxt.js +7 -0
- package/dist/capture/adapters/rails.js +7 -0
- package/dist/capture/adapters/react-router.js +7 -0
- package/dist/capture/adapters/sveltekit.js +7 -0
- package/dist/capture/adapters/vite.js +14 -0
- package/dist/capture/app-config.js +2 -1
- package/dist/capture/csp.js +7 -6
- package/dist/capture/url-rewrites.js +232 -0
- package/dist/capture-step.js +3 -0
- package/dist/commands/capture-manifest.js +25 -17
- package/dist/commands/init-capture.js +44 -27
- package/dist/commands/init-telemetry.js +24 -7
- package/dist/commands/init.js +12 -2
- package/dist/commands/verify-onboarding.js +16 -0
- package/dist/commands/verify.js +2 -2
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/schemas/init.v1.json +1 -1
|
@@ -132,6 +132,13 @@ export async function detectRails(ctx) {
|
|
|
132
132
|
label: `Rails ${version?.version ?? '(unknown version)'}`,
|
|
133
133
|
manifestAdapter: `rails@${version?.version ?? 'unknown'}`,
|
|
134
134
|
blocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
135
|
+
// The routes come from the source, so whatever stops init placing the tag stops the manifest too.
|
|
136
|
+
manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
137
|
+
fromBuild: false,
|
|
138
|
+
rewriting: null,
|
|
139
|
+
publishNote: null,
|
|
140
|
+
buildPublishDirs: () => [],
|
|
141
|
+
buildNotes: () => [],
|
|
135
142
|
looked: [layoutsPath, repoPath(ctx, join(ctx.appDir, 'Gemfile.lock'))],
|
|
136
143
|
staticDir: 'public',
|
|
137
144
|
async integrate(tags) {
|
|
@@ -412,6 +412,13 @@ export async function detectReactRouter(ctx) {
|
|
|
412
412
|
label,
|
|
413
413
|
manifestAdapter: `${reactRouter ? 'react-router' : 'remix'}@${version.version}`,
|
|
414
414
|
blocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
415
|
+
// The routes come from the source, so whatever stops init placing the tag stops the manifest too.
|
|
416
|
+
manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
417
|
+
fromBuild: false,
|
|
418
|
+
rewriting: null,
|
|
419
|
+
publishNote: null,
|
|
420
|
+
buildPublishDirs: () => [],
|
|
421
|
+
buildNotes: () => [],
|
|
415
422
|
looked,
|
|
416
423
|
staticDir: 'public',
|
|
417
424
|
integrate: tags => rootEdit(ctx, root, tags),
|
|
@@ -71,6 +71,13 @@ export async function detectSvelteKit(ctx) {
|
|
|
71
71
|
label: `SvelteKit ${version.version}`,
|
|
72
72
|
manifestAdapter: `sveltekit@${version.version}`,
|
|
73
73
|
blocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
74
|
+
// The routes come from the source, so whatever stops init placing the tag stops the manifest too.
|
|
75
|
+
manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
76
|
+
fromBuild: false,
|
|
77
|
+
rewriting: null,
|
|
78
|
+
publishNote: null,
|
|
79
|
+
buildPublishDirs: () => [],
|
|
80
|
+
buildNotes: () => [],
|
|
74
81
|
looked: [templatePath, where, repoPath(ctx, join(ctx.appDir, routesDir))],
|
|
75
82
|
staticDir: assets,
|
|
76
83
|
async integrate(tags) {
|
|
@@ -79,6 +79,13 @@ export async function detectVite(ctx) {
|
|
|
79
79
|
label: `Vite ${version.version}${router ? ` with ${router}` : ''}`,
|
|
80
80
|
manifestAdapter: `vite@${version.version}`,
|
|
81
81
|
blocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
82
|
+
// The routes come from the source, so whatever stops init placing the tag stops the manifest too.
|
|
83
|
+
manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
|
|
84
|
+
fromBuild: false,
|
|
85
|
+
rewriting: null,
|
|
86
|
+
publishNote: null,
|
|
87
|
+
buildPublishDirs: () => [],
|
|
88
|
+
buildNotes: () => [],
|
|
82
89
|
looked: [indexPath, where, repoPath(ctx, srcDir)],
|
|
83
90
|
staticDir: toPosix(posix.join(toPosix(root), toPosix(publicDir))),
|
|
84
91
|
integrate: async (tags) => htmlEdit(index, tags, indexPath),
|
|
@@ -109,6 +116,13 @@ export async function detectStaticHtml(ctx) {
|
|
|
109
116
|
label: 'a static HTML site',
|
|
110
117
|
manifestAdapter: 'static-html',
|
|
111
118
|
blocked: pages.length === 1 ? null : `the site has ${pages.length} HTML pages (${pages.slice(0, 5).join(', ')}${pages.length > 5 ? ', …' : ''}), each needing the tag`,
|
|
119
|
+
// The routes come from the source, so whatever stops init placing the tag stops the manifest too.
|
|
120
|
+
manifestBlocked: pages.length === 1 ? null : `the site has ${pages.length} HTML pages (${pages.slice(0, 5).join(', ')}${pages.length > 5 ? ', …' : ''}), each needing the tag`,
|
|
121
|
+
fromBuild: false,
|
|
122
|
+
rewriting: null,
|
|
123
|
+
publishNote: null,
|
|
124
|
+
buildPublishDirs: () => [],
|
|
125
|
+
buildNotes: () => [],
|
|
112
126
|
looked: [indexPath],
|
|
113
127
|
staticDir: '.',
|
|
114
128
|
integrate: async (tags) => htmlEdit(index, tags, indexPath),
|
|
@@ -36,7 +36,8 @@ export function readCaptureConfig(appDir) {
|
|
|
36
36
|
if (parsed.version !== CAPTURE_VERSION || strings.some(name => typeof parsed[name] !== 'string')
|
|
37
37
|
|| !CAPTURE_KEY_PATTERN.test(parsed.key) || !(parsed.adapter === null || typeof parsed.adapter === 'string')
|
|
38
38
|
|| !Array.isArray(parsed.origins) || !parsed.origins.every(origin => typeof origin === 'string')
|
|
39
|
-
|| !Array.isArray(parsed.publish) || !parsed.publish.every(dir => typeof dir === 'string')
|
|
39
|
+
|| !Array.isArray(parsed.publish) || !parsed.publish.every(dir => typeof dir === 'string')
|
|
40
|
+
|| !(parsed.urlRewrites === undefined || parsed.urlRewrites === null || typeof parsed.urlRewrites === 'string')) {
|
|
40
41
|
throw new Error(`${CAPTURE_CONFIG_PATH} is not a capture configuration init wrote; remove it and run \`haystack init\` again.`);
|
|
41
42
|
}
|
|
42
43
|
return parsed;
|
package/dist/capture/csp.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import fg from 'fast-glob';
|
|
13
13
|
import { readFileSync, statSync } from 'node:fs';
|
|
14
|
-
import { extname, join } from 'node:path';
|
|
14
|
+
import { extname, join, relative } from 'node:path';
|
|
15
15
|
import { SAXParser } from 'parse5-sax-parser';
|
|
16
16
|
import { CAPTURE_ORIGIN } from '../commands/capture-contract.js';
|
|
17
17
|
import { keyName, parseFile, parseJsonLike, unwrapTs, walk } from './js-ast.js';
|
|
@@ -159,7 +159,7 @@ function jsPolicies(text, file, json) {
|
|
|
159
159
|
? (value.properties.find((property) => property.type === 'ObjectProperty' && keyName(property) === 'directives')?.value ?? value) : value;
|
|
160
160
|
const policy = resolvePolicy(directives, ast);
|
|
161
161
|
// `csp` is a common key for other things; it counts only when it spells a policy.
|
|
162
|
-
if (name !== 'csp' || policy !== null)
|
|
162
|
+
if (name !== 'csp' || (policy !== null && DIRECTIVE.test(policy)))
|
|
163
163
|
found.push({ offset: node.start, policy, reportOnly: name.endsWith('report-only') });
|
|
164
164
|
seen.add(node.value);
|
|
165
165
|
}
|
|
@@ -203,8 +203,9 @@ function resolvePolicy(node, ast, depth = 0) {
|
|
|
203
203
|
&& ['replace', 'replaceAll', 'trim'].includes(current.callee.property.name)) {
|
|
204
204
|
return resolvePolicy(current.callee.object, ast, depth + 1);
|
|
205
205
|
}
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
// A header's value is its policy whatever it says (`frame-ancestors 'none'` restricts nothing the tag does); a computed
|
|
207
|
+
// value init cannot read stays null.
|
|
208
|
+
return policyFromNode(current);
|
|
208
209
|
}
|
|
209
210
|
/** helmet()'s default policy (helmet 5–8), which applies when its options leave contentSecurityPolicy alone. */
|
|
210
211
|
const HELMET_DEFAULT = "default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self';"
|
|
@@ -298,7 +299,7 @@ export function findCsp(repoRoot, appDir, nextLoader, origins) {
|
|
|
298
299
|
if (!MARKER.test(text))
|
|
299
300
|
continue;
|
|
300
301
|
const extension = extname(file);
|
|
301
|
-
const where = toPosix(
|
|
302
|
+
const where = toPosix(relative(repoRoot, file));
|
|
302
303
|
let policies;
|
|
303
304
|
if (['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx'].includes(extension))
|
|
304
305
|
policies = jsPolicies(text, file, false);
|
|
@@ -327,5 +328,5 @@ export function findCsp(repoRoot, appDir, nextLoader, origins) {
|
|
|
327
328
|
});
|
|
328
329
|
}
|
|
329
330
|
}
|
|
330
|
-
return { findings, looked: `${toPosix(
|
|
331
|
+
return { findings, looked: `${toPosix(relative(repoRoot, appDir)) || '.'} (headers config, middleware, meta tags, framework CSP options, server configs)` };
|
|
331
332
|
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL rewrites (CAPTURE-V1 rule 4). The tag matches a page's URL against the manifest's routes, so a route's rule must say
|
|
3
|
+
* which URLs show it. A middleware or proxy that rewrites (Rallly's proxy turns every /x into /<locale>/x) or a config
|
|
4
|
+
* rewrite into a page makes the URL and the route's path differ in ways only the app's code says. Init reads that code
|
|
5
|
+
* only where it can read it exactly (a middleware that rewrites nothing and calls nothing it cannot see into, or
|
|
6
|
+
* next-intl's middleware with a literal routing config); for anything else it never guesses: it asks the coding agent's
|
|
7
|
+
* user (who can read that code) for one answer, recorded in .haystack/capture.json:
|
|
8
|
+
*
|
|
9
|
+
* none rewrites never make a page's URL show another page
|
|
10
|
+
* hidden-segment:<name> the middleware or proxy adds the leading dynamic segment [<name>] to every page path, so
|
|
11
|
+
* URLs never contain it (the manifest drops that segment from the routes under it)
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { keyName, literalValue, parseFile, pathOf, readPathAliases, resolveImport, unwrapTs, UNREADABLE, walk } from './js-ast.js';
|
|
16
|
+
import { isFile } from './adapters/files.js';
|
|
17
|
+
import { repoPath, resolveVersion } from './project.js';
|
|
18
|
+
export function parseUrlRewrites(raw) {
|
|
19
|
+
const value = raw.trim();
|
|
20
|
+
if (value === 'none')
|
|
21
|
+
return { kind: 'none' };
|
|
22
|
+
const hidden = /^hidden-segment:([A-Za-z0-9_-]+)$/.exec(value);
|
|
23
|
+
if (hidden)
|
|
24
|
+
return { kind: 'hidden-segment', name: hidden[1] };
|
|
25
|
+
throw new Error(`--url-rewrites ${raw} is not an answer: use none, or hidden-segment:<name> for a leading [<name>] segment the app's middleware or proxy adds to every page path.`);
|
|
26
|
+
}
|
|
27
|
+
export const URL_REWRITES_QUESTION = 'Does it make a page\'s URL show another page? Ask your user (or read that code), then run `haystack init` again with'
|
|
28
|
+
+ ' `--url-rewrites none` when its rewrites never do, or `--url-rewrites hidden-segment:<name>` when it adds a leading [<name>] segment to every'
|
|
29
|
+
+ ' page path (so URLs never contain it).';
|
|
30
|
+
const MIDDLEWARE_FILES = ['middleware', 'proxy'].flatMap(name => ['ts', 'js', 'mjs', 'mts', 'cts', 'cjs', 'tsx', 'jsx'].map(extension => `${name}.${extension}`));
|
|
31
|
+
const INTL_MIDDLEWARE = 'next-intl/middleware';
|
|
32
|
+
const INTL_ROUTING = 'next-intl/routing';
|
|
33
|
+
/** next/server and the rest of Next's own modules: what they return init knows (NextResponse.rewrite is caught by name). */
|
|
34
|
+
function nextModule(source) {
|
|
35
|
+
return source === 'next' || source.startsWith('next/');
|
|
36
|
+
}
|
|
37
|
+
/** The identifier a call's callee starts from (`a.b.c()` → a), or null (a call on a call's result, `(…)()`). */
|
|
38
|
+
function calleeRoot(callee) {
|
|
39
|
+
let current = unwrapTs(callee);
|
|
40
|
+
while (current.type === 'MemberExpression' || current.type === 'OptionalMemberExpression')
|
|
41
|
+
current = unwrapTs(current.object);
|
|
42
|
+
return current.type === 'Identifier' ? current : null;
|
|
43
|
+
}
|
|
44
|
+
/** The value a routing config names: an object literal, a top-level const bound to one or to defineRouting({...}), or
|
|
45
|
+
* the same exported from a local module; null when it is not literally there. */
|
|
46
|
+
function routingObject(node, ast, file, ctx, depth = 0) {
|
|
47
|
+
const current = unwrapTs(node);
|
|
48
|
+
if (depth > 4)
|
|
49
|
+
return null;
|
|
50
|
+
if (current.type === 'ObjectExpression')
|
|
51
|
+
return { value: current, ast };
|
|
52
|
+
if (current.type === 'CallExpression' && current.arguments.length === 1) {
|
|
53
|
+
const root = calleeRoot(current.callee);
|
|
54
|
+
const source = root ? importOf(ast, root.name) : null;
|
|
55
|
+
if (source?.module === INTL_ROUTING && source.imported === 'defineRouting')
|
|
56
|
+
return routingObject(current.arguments[0], ast, file, ctx, depth + 1);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (current.type !== 'Identifier')
|
|
60
|
+
return null;
|
|
61
|
+
for (const statement of ast.program.body) {
|
|
62
|
+
const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement;
|
|
63
|
+
if (declaration?.type !== 'VariableDeclaration')
|
|
64
|
+
continue;
|
|
65
|
+
for (const declarator of declaration.declarations) {
|
|
66
|
+
if (declarator.id.type === 'Identifier' && declarator.id.name === current.name && declarator.init) {
|
|
67
|
+
return routingObject(declarator.init, ast, file, ctx, depth + 1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const imported = importOf(ast, current.name);
|
|
72
|
+
if (!imported)
|
|
73
|
+
return null;
|
|
74
|
+
const target = resolveImport(file, imported.module, readPathAliases(ctx.appDir));
|
|
75
|
+
if (!target)
|
|
76
|
+
return null;
|
|
77
|
+
const targetAst = parseFile(readFileSync(target, 'utf8'), target);
|
|
78
|
+
if (!targetAst)
|
|
79
|
+
return null;
|
|
80
|
+
const exported = exportedExpression(targetAst, imported.imported);
|
|
81
|
+
return exported ? routingObject(exported, targetAst, target, ctx, depth + 1) : null;
|
|
82
|
+
}
|
|
83
|
+
function importOf(ast, local) {
|
|
84
|
+
for (const statement of ast.program.body) {
|
|
85
|
+
if (statement.type !== 'ImportDeclaration')
|
|
86
|
+
continue;
|
|
87
|
+
for (const specifier of statement.specifiers) {
|
|
88
|
+
if (specifier.local.name !== local)
|
|
89
|
+
continue;
|
|
90
|
+
const imported = specifier.type === 'ImportDefaultSpecifier' ? 'default'
|
|
91
|
+
: specifier.type === 'ImportNamespaceSpecifier' ? '*' : specifier.imported.name ?? specifier.imported.value;
|
|
92
|
+
return { module: statement.source.value, imported };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
/** The expression a module exports under `name` ('default' for its default export), or null. */
|
|
98
|
+
function exportedExpression(ast, name) {
|
|
99
|
+
for (const statement of ast.program.body) {
|
|
100
|
+
if (name === 'default' && statement.type === 'ExportDefaultDeclaration')
|
|
101
|
+
return statement.declaration;
|
|
102
|
+
if (statement.type !== 'ExportNamedDeclaration' || statement.source)
|
|
103
|
+
continue;
|
|
104
|
+
for (const declarator of statement.declaration?.type === 'VariableDeclaration' ? statement.declaration.declarations : []) {
|
|
105
|
+
if (declarator.id.type === 'Identifier' && declarator.id.name === name)
|
|
106
|
+
return declarator.init;
|
|
107
|
+
}
|
|
108
|
+
for (const specifier of statement.specifiers ?? []) {
|
|
109
|
+
if ((specifier.exported?.name ?? specifier.exported?.value) === name)
|
|
110
|
+
return specifier.local;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/** next-intl's routing, read exactly, or why it cannot be. */
|
|
116
|
+
function intlRouting(config, ast, file, ctx, where) {
|
|
117
|
+
const unknown = (why) => ({ kind: 'unknown', why: `${where} uses next-intl's middleware, ${why}` });
|
|
118
|
+
const found = config ? routingObject(config, ast, file, ctx) : null;
|
|
119
|
+
if (!found)
|
|
120
|
+
return unknown('whose routing config init cannot read literally');
|
|
121
|
+
const value = literalValue(found.value, found.ast);
|
|
122
|
+
if (value === UNREADABLE || typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
123
|
+
return unknown('whose routing config is not literal');
|
|
124
|
+
}
|
|
125
|
+
const routing = value;
|
|
126
|
+
if (routing.pathnames !== undefined)
|
|
127
|
+
return unknown('whose localized pathnames make a URL show a page under another path');
|
|
128
|
+
if (routing.domains !== undefined)
|
|
129
|
+
return unknown('whose domains decide the locale by host');
|
|
130
|
+
const locales = Array.isArray(routing.locales) && routing.locales.every(locale => typeof locale === 'string') ? routing.locales : null;
|
|
131
|
+
const defaultLocale = typeof routing.defaultLocale === 'string' ? routing.defaultLocale : null;
|
|
132
|
+
if (!locales || !defaultLocale)
|
|
133
|
+
return unknown('whose locales or defaultLocale are not a literal list and string');
|
|
134
|
+
let mode;
|
|
135
|
+
let prefixes = {};
|
|
136
|
+
const prefix = routing.localePrefix;
|
|
137
|
+
if (prefix === undefined) {
|
|
138
|
+
// next-intl 3 made `always` the default; 2 had `as-needed`.
|
|
139
|
+
const version = resolveVersion(ctx, 'next-intl');
|
|
140
|
+
if (!version || version.major === null)
|
|
141
|
+
return unknown('whose default localePrefix depends on a next-intl version init cannot resolve');
|
|
142
|
+
mode = version.major >= 3 ? 'always' : 'as-needed';
|
|
143
|
+
}
|
|
144
|
+
else if (typeof prefix === 'string') {
|
|
145
|
+
mode = prefix;
|
|
146
|
+
}
|
|
147
|
+
else if (prefix && typeof prefix === 'object' && !Array.isArray(prefix)) {
|
|
148
|
+
mode = prefix.mode;
|
|
149
|
+
if (prefix.prefixes !== undefined) {
|
|
150
|
+
const given = prefix.prefixes;
|
|
151
|
+
if (!given || typeof given !== 'object' || Array.isArray(given) || !Object.values(given).every(item => typeof item === 'string' && item.startsWith('/'))) {
|
|
152
|
+
return unknown('whose localePrefix.prefixes are not literal paths');
|
|
153
|
+
}
|
|
154
|
+
prefixes = given;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (mode !== 'always' && mode !== 'as-needed' && mode !== 'never')
|
|
158
|
+
return unknown(`whose localePrefix mode (${JSON.stringify(mode)}) init does not know`);
|
|
159
|
+
return { kind: 'locale-prefix', where, mode, locales, defaultLocale, prefixes };
|
|
160
|
+
}
|
|
161
|
+
/** One middleware or proxy file, read. */
|
|
162
|
+
function readMiddleware(ctx, file) {
|
|
163
|
+
const where = repoPath(ctx, file);
|
|
164
|
+
const ast = parseFile(readFileSync(file, 'utf8'), file);
|
|
165
|
+
if (!ast)
|
|
166
|
+
return { kind: 'unknown', why: `${where} (which init cannot parse)` };
|
|
167
|
+
const program = pathOf(ast, ast.program);
|
|
168
|
+
if (!program)
|
|
169
|
+
return { kind: 'unknown', why: `${where} (whose names init cannot resolve)` };
|
|
170
|
+
const reasons = [];
|
|
171
|
+
walk(ast.program, node => {
|
|
172
|
+
if (node.type === 'ExportAllDeclaration' || (node.type === 'ExportNamedDeclaration' && node.source)) {
|
|
173
|
+
reasons.push(`re-exports from ${node.source.value}`);
|
|
174
|
+
}
|
|
175
|
+
if (node.type === 'StringLiteral' && node.value.toLowerCase() === 'x-middleware-rewrite')
|
|
176
|
+
reasons.push('sets the x-middleware-rewrite header');
|
|
177
|
+
if ((node.type === 'CallExpression' || node.type === 'OptionalCallExpression') && node.callee.type === 'MemberExpression' && !node.callee.computed
|
|
178
|
+
&& node.callee.property.name === 'rewrite')
|
|
179
|
+
reasons.push('calls rewrite()');
|
|
180
|
+
if (node.type === 'ObjectProperty' && keyName(node) === 'rewrite')
|
|
181
|
+
reasons.push('passes a rewrite');
|
|
182
|
+
if (node.type === 'ImportExpression' || (node.type === 'CallExpression' && (node.callee.type === 'Import'
|
|
183
|
+
|| (node.callee.type === 'Identifier' && node.callee.name === 'require'))))
|
|
184
|
+
reasons.push('loads a module at runtime');
|
|
185
|
+
});
|
|
186
|
+
// Every value imported from outside Next is code init cannot see into, wherever it is used (called, passed to a local
|
|
187
|
+
// helper that calls it, exported as the middleware), except next-intl's createMiddleware and the routing it is given,
|
|
188
|
+
// which init reads.
|
|
189
|
+
const intlCalls = [];
|
|
190
|
+
for (const [local, binding] of Object.entries(program.scope.bindings)) {
|
|
191
|
+
const declaration = binding.path.parentPath?.node;
|
|
192
|
+
if (binding.kind !== 'module' || declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type'
|
|
193
|
+
|| binding.path.node.importKind === 'type' || nextModule(declaration.source.value))
|
|
194
|
+
continue;
|
|
195
|
+
const factory = declaration.source.value === INTL_MIDDLEWARE && binding.path.node.type === 'ImportDefaultSpecifier';
|
|
196
|
+
for (const reference of binding.referencePaths) {
|
|
197
|
+
const parent = reference.parentPath?.node;
|
|
198
|
+
if (factory && parent?.type === 'CallExpression' && parent.callee === reference.node) {
|
|
199
|
+
intlCalls.push(parent);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const factoryArgument = parent?.type === 'CallExpression' && parent.arguments[0] === reference.node && parent.callee.type === 'Identifier'
|
|
203
|
+
&& program.scope.getBinding(parent.callee.name)?.path.parentPath?.node.source?.value === INTL_MIDDLEWARE;
|
|
204
|
+
if (factoryArgument)
|
|
205
|
+
continue;
|
|
206
|
+
reasons.push(`uses ${local} from ${declaration.source.value}`);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (reasons.length > 0)
|
|
211
|
+
return { kind: 'unknown', why: `${where}, which ${reasons[0]} (init cannot read what that does to page paths)` };
|
|
212
|
+
if (intlCalls.length === 0)
|
|
213
|
+
return { kind: 'none' };
|
|
214
|
+
// next-intl: every createMiddleware call must read as the same routing.
|
|
215
|
+
const readings = intlCalls.map(call => intlRouting(call.arguments[0], ast, file, ctx, where));
|
|
216
|
+
const first = readings[0];
|
|
217
|
+
if (first.kind !== 'locale-prefix')
|
|
218
|
+
return first;
|
|
219
|
+
return readings.every(reading => JSON.stringify(reading) === JSON.stringify(first)) ? first
|
|
220
|
+
: { kind: 'unknown', why: `${where}, which calls next-intl's createMiddleware with several routing configs` };
|
|
221
|
+
}
|
|
222
|
+
/** What the app's middleware or proxy does to page paths (the first one found, as Next loads one). Needs loadBabel(). */
|
|
223
|
+
export function middlewareRewrites(ctx) {
|
|
224
|
+
for (const dir of [ctx.appDir, join(ctx.appDir, 'src')]) {
|
|
225
|
+
for (const name of MIDDLEWARE_FILES) {
|
|
226
|
+
const file = join(dir, name);
|
|
227
|
+
if (isFile(file))
|
|
228
|
+
return readMiddleware(ctx, file);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { kind: 'none' };
|
|
232
|
+
}
|
package/dist/capture-step.js
CHANGED
|
@@ -42,6 +42,9 @@ try {
|
|
|
42
42
|
if (written) {
|
|
43
43
|
const files = Array.isArray(result.files) ? result.files.join(', ') : '';
|
|
44
44
|
console.log(`Haystack route manifest ${String(result.release)}: ${files}`);
|
|
45
|
+
// What the person must still do for it to be reachable (a Next.js basePath): said on every build until done.
|
|
46
|
+
for (const note of Array.isArray(result.notes) ? result.notes : [])
|
|
47
|
+
console.warn(`haystack capture manifest: ${String(note)}.`);
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
catch { /* the command did not answer: no manifest was written */ }
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
* current.json. No credential is involved: whoever deploys the app publishes its manifest.
|
|
6
6
|
*
|
|
7
7
|
* It runs inside the customer's build (the package script init extends, or the Rails rake file), from the CLI the app pins
|
|
8
|
-
* as an exact
|
|
8
|
+
* as an exact production dependency. It never fails that build (rule 13): whatever goes wrong, it prints one warning line and exits
|
|
9
9
|
* 0, because a missing manifest only stops the tag on this release (rule 13a: no manifest, no work). It needs no git
|
|
10
10
|
* checkout: repository paths come from the app path init recorded.
|
|
11
11
|
*/
|
|
12
12
|
import chalk from 'chalk';
|
|
13
13
|
import { relative, resolve, sep } from 'node:path';
|
|
14
|
-
import { detectApp } from '../capture/adapters/index.js';
|
|
14
|
+
import { ADAPTER_IDS, detectApp, detectWith } from '../capture/adapters/index.js';
|
|
15
15
|
import { isManual } from '../capture/adapters/types.js';
|
|
16
16
|
import { CAPTURE_CONFIG_PATH, readCaptureConfig } from '../capture/app-config.js';
|
|
17
17
|
import { loadBabel } from '../capture/js-ast.js';
|
|
@@ -44,35 +44,43 @@ async function publish(options, locations) {
|
|
|
44
44
|
if (!config)
|
|
45
45
|
throw new Error(`${appDir} has no ${CAPTURE_CONFIG_PATH}: run \`haystack init\` in the repository to set capture up.`);
|
|
46
46
|
locations.push(...config.publish.map(dir => resolve(appDir, dir)));
|
|
47
|
-
if (
|
|
48
|
-
throw new Error(
|
|
47
|
+
if (options.adapter !== undefined && !ADAPTER_IDS.includes(options.adapter)) {
|
|
48
|
+
throw new Error(`--adapter ${options.adapter} is not one of ${ADAPTER_IDS.join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
const adapter = (options.adapter ?? config.adapter);
|
|
51
|
+
if (adapter === null)
|
|
52
|
+
throw new Error(`${CAPTURE_CONFIG_PATH} names no framework adapter: run it with --adapter <${ADAPTER_IDS.join('|')}>`);
|
|
49
53
|
// The checkout's root when the app sits at its recorded path; else (a build that sees only the app) the app itself.
|
|
50
54
|
const suffix = config.app === '.' ? '' : config.app.split('/').join(sep);
|
|
51
55
|
const repoRoot = suffix !== '' && appDir.endsWith(`${sep}${suffix}`) ? appDir.slice(0, -suffix.length - 1) : appDir;
|
|
52
56
|
await loadBabel();
|
|
53
57
|
const ctx = appContext(repoRoot, appDir, config.app);
|
|
54
|
-
const detected = await
|
|
55
|
-
if (!detected
|
|
56
|
-
throw new Error(
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
if (detected.blocked)
|
|
61
|
-
throw new Error(`${detected.label}: ${detected.blocked}.`);
|
|
62
|
-
const routes = exactRoutes(await detected.routes({ railsRoutes: options.railsRoutes ? resolve(options.railsRoutes) : undefined }));
|
|
63
|
-
if (isManual(routes))
|
|
64
|
-
throw new Error(routes.manual);
|
|
58
|
+
const detected = await detectWith(ctx, adapter);
|
|
59
|
+
if (!detected)
|
|
60
|
+
throw new Error(`the ${adapter} adapter does not recognize ${appDir} (${(await detectApp(ctx))?.label ?? 'no framework init recognizes'}); run \`haystack init\` again`);
|
|
61
|
+
// Checked on every build, so once the user removes the cause the next build publishes, with nothing to rerun.
|
|
62
|
+
if (detected.manifestBlocked)
|
|
63
|
+
throw new Error(`${detected.label}: ${detected.manifestBlocked}`);
|
|
65
64
|
if (isManual(detected.publishDirs))
|
|
66
65
|
throw new Error(detected.publishDirs.manual);
|
|
66
|
+
const publishDirs = [...detected.publishDirs, ...detected.buildPublishDirs()].map(dir => resolve(appDir, dir));
|
|
67
|
+
locations.push(...publishDirs.filter(dir => !locations.includes(dir)));
|
|
68
|
+
const routes = exactRoutes(await detected.routes({ railsRoutes: options.railsRoutes ? resolve(options.railsRoutes) : undefined,
|
|
69
|
+
urlRewrites: config.urlRewrites ?? null }));
|
|
70
|
+
if (isManual(routes))
|
|
71
|
+
throw new Error(routes.manual);
|
|
67
72
|
const manifest = buildManifest(config, detected.manifestAdapter, routes, detected.chunks());
|
|
68
|
-
const written = writeManifest(
|
|
73
|
+
const written = writeManifest(publishDirs, manifest);
|
|
74
|
+
const notes = detected.buildNotes();
|
|
69
75
|
if (options.json) {
|
|
70
76
|
process.stdout.write(`${JSON.stringify({ written: true, release: manifest.release, routes: manifest.routes.length, chunks: manifest.chunks.length,
|
|
71
|
-
files: written.map(file => relative(appDir, file)) }, null, 2)}\n`);
|
|
77
|
+
files: written.map(file => relative(appDir, file)), notes }, null, 2)}\n`);
|
|
72
78
|
return;
|
|
73
79
|
}
|
|
74
80
|
const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
75
81
|
console.log(chalk.green(`Haystack route manifest ${manifest.release}: ${count(manifest.routes.length, 'route')}, ${count(manifest.chunks.length, 'chunk')} (${detected.label})`));
|
|
76
82
|
for (const file of written)
|
|
77
83
|
console.log(chalk.dim(` ${relative(appDir, file)}`));
|
|
84
|
+
for (const note of notes)
|
|
85
|
+
console.warn(chalk.yellow(`haystack capture manifest: ${note}.`));
|
|
78
86
|
}
|
|
@@ -31,8 +31,9 @@ import { findCsp } from '../capture/csp.js';
|
|
|
31
31
|
import { applyEdits, keyName, loadBabel, parseJsonLike } from '../capture/js-ast.js';
|
|
32
32
|
import { buildManifest, exactRoutes, manifestDocument, MANIFEST_DIR } from '../capture/manifest.js';
|
|
33
33
|
import { appContext } from '../capture/project.js';
|
|
34
|
+
import { parseUrlRewrites, URL_REWRITES_QUESTION } from '../capture/url-rewrites.js';
|
|
34
35
|
import { loadCaptureScript } from '../capture/script-release.js';
|
|
35
|
-
import {
|
|
36
|
+
import { STATIC_SUBDIR } from '../capture/tag.js';
|
|
36
37
|
import { CAPTURE_ORIGIN, CAPTURE_VERSION } from './capture-contract.js';
|
|
37
38
|
import { HaystackApiError } from '../utils/haystack-api.js';
|
|
38
39
|
import { lineDiff } from './init.js';
|
|
@@ -50,9 +51,10 @@ const COMPOSABLE_SCRIPT = /^[A-Za-z0-9_./=:@\s&-]+$/;
|
|
|
50
51
|
const STAGED_DIR = `.haystack/${STATIC_SUBDIR}`;
|
|
51
52
|
/** The answers in effect: the flags, else the ones init recorded in .haystack/capture.json (rule 8b: the user's answers,
|
|
52
53
|
* never inferred, so a rerun that only updates the script needs none). */
|
|
53
|
-
export function captureAnswers(appDir, origins, consent) {
|
|
54
|
+
export function captureAnswers(appDir, origins, consent, urlRewrites) {
|
|
54
55
|
const recorded = readCaptureConfig(appDir);
|
|
55
|
-
return { origins: origins.length > 0 ? origins : recorded?.origins ?? [], consent: consent ?? recorded?.consent
|
|
56
|
+
return { origins: origins.length > 0 ? origins : recorded?.origins ?? [], consent: consent ?? recorded?.consent,
|
|
57
|
+
urlRewrites: urlRewrites ?? recorded?.urlRewrites ?? undefined };
|
|
56
58
|
}
|
|
57
59
|
function readText(path) {
|
|
58
60
|
try {
|
|
@@ -126,6 +128,9 @@ function scriptFiles(dir) {
|
|
|
126
128
|
return [];
|
|
127
129
|
}
|
|
128
130
|
}
|
|
131
|
+
function formatUrlRewrites(rewrites) {
|
|
132
|
+
return rewrites.kind === 'none' ? 'none' : `hidden-segment:${rewrites.name}`;
|
|
133
|
+
}
|
|
129
134
|
function manifestCommand(appPath) {
|
|
130
135
|
return `haystack capture manifest --app ${appPath}`;
|
|
131
136
|
}
|
|
@@ -144,6 +149,7 @@ export async function planCapture(input) {
|
|
|
144
149
|
if (input.answers.consent === undefined)
|
|
145
150
|
throw new Error('planCapture needs the consent answer.');
|
|
146
151
|
const consent = parseConsentChoice(input.answers.consent);
|
|
152
|
+
const urlRewrites = input.answers.urlRewrites === undefined ? null : formatUrlRewrites(parseUrlRewrites(input.answers.urlRewrites));
|
|
147
153
|
const plan = { changes: [], packageText: null, needsCli: false, registration: null, notices: [], part: part([]) };
|
|
148
154
|
if (input.registration && 'failed' in input.registration) {
|
|
149
155
|
plan.part = part([`Run \`haystack init\` again: registering the app for capture failed (${input.registration.failed}).`]);
|
|
@@ -182,18 +188,36 @@ export async function planCapture(input) {
|
|
|
182
188
|
key: tags.key,
|
|
183
189
|
repositoryId: identity?.repositoryId ?? ID_PLACEHOLDER,
|
|
184
190
|
applicationId: identity?.applicationId ?? ID_PLACEHOLDER,
|
|
185
|
-
adapter
|
|
191
|
+
// The adapter init recognized, whatever it left manual: the manifest command uses it once the cause is fixed.
|
|
192
|
+
adapter: detected ? detected.id : null,
|
|
186
193
|
app: ctx.appPath,
|
|
187
194
|
origins,
|
|
188
195
|
consent: formatConsentChoice(consent),
|
|
189
196
|
// Where a build step publishes (none for a static site, whose manifest init writes and the repository keeps).
|
|
190
|
-
publish: detected && !detected.
|
|
191
|
-
|
|
197
|
+
publish: detected && !isManual(detected.publishDirs) && !isManual(detected.build) && detected.build.kind !== 'init-writes'
|
|
198
|
+
? detected.publishDirs : [],
|
|
199
|
+
urlRewrites,
|
|
192
200
|
};
|
|
193
201
|
planner.text(captureConfigFile(appDir), formatCaptureConfig(config), 'The app\'s capture identity, committed for the build step that publishes its route manifest (CAPTURE-V1 rule 8b).');
|
|
194
202
|
const consentStep = manualConsentStep(consent);
|
|
195
|
-
|
|
196
|
-
|
|
203
|
+
if (!detected) {
|
|
204
|
+
plan.part = { status: 'unsupported', why: `init recognized no web framework at ${ctx.appPath} (it looked for package.json, Gemfile,`
|
|
205
|
+
+ ' manage.py and index.html there), and the tag records nothing without the route manifest only a recognized framework\'s routes'
|
|
206
|
+
+ ' give; run `haystack init --app <dir>` with the app\'s directory' };
|
|
207
|
+
plan.changes = planner.changes;
|
|
208
|
+
return plan;
|
|
209
|
+
}
|
|
210
|
+
// A manifest that cannot be made exactly (a base path, a config init cannot read) leaves nothing worth placing: a tag
|
|
211
|
+
// without a manifest records nothing. Two steps that work: remove the cause, run init again (the recorded answers carry
|
|
212
|
+
// over, and the manifest command checks the cause on every build).
|
|
213
|
+
if (detected.manifestBlocked) {
|
|
214
|
+
steps.push(`Remove what stops ${detected.label}'s route manifest: ${detected.manifestBlocked} (init looked at ${detected.looked.join(', ')}).`);
|
|
215
|
+
steps.push('Then run `haystack init` again: it places the tag, the files and the build step that publishes the manifest itself.');
|
|
216
|
+
plan.changes = planner.changes;
|
|
217
|
+
plan.part = part(steps);
|
|
218
|
+
return plan;
|
|
219
|
+
}
|
|
220
|
+
const staticDir = typeof detected.staticDir === 'string' ? detected.staticDir : null;
|
|
197
221
|
// The files: in the app's static output, or staged for a manual step.
|
|
198
222
|
const filesDir = staticDir === null ? join(appDir, STAGED_DIR) : join(appDir, staticDir, STATIC_SUBDIR);
|
|
199
223
|
const wanted = new Set([script.file, ...(consentFile ? [consentFile.file] : [])]);
|
|
@@ -205,24 +229,8 @@ export async function planCapture(input) {
|
|
|
205
229
|
if (!wanted.has(old))
|
|
206
230
|
planner.remove(join(filesDir, old), 'An earlier capture file, replaced by this version.');
|
|
207
231
|
const servedAt = `/${STATIC_SUBDIR}/`;
|
|
208
|
-
if (!automatic) {
|
|
209
|
-
const why = detected ? `${detected.label}: ${detected.blocked}` : `init recognized no framework at ${ctx.appPath}`;
|
|
210
|
-
const looked = detected?.looked.join(', ') ?? `${ctx.appPath} (package.json, Gemfile, manage.py, index.html)`;
|
|
211
|
-
steps.push(`Add the tag yourself (${why}; init looked at ${looked}):\n${(detected ? detected.manualTag(tags) : ['Inside <head> of every page:', ...htmlTags(tags)]).map(line => ` ${line}`).join('\n')}`);
|
|
212
|
-
steps.push(`Serve the files init staged in ${posix.join(ctx.appPath, STAGED_DIR)}/ at ${servedAt} on each production origin.`);
|
|
213
|
-
steps.push(`Publish the route manifest (CAPTURE-V1 rule 4) after each build at /${MANIFEST_DIR}/current.json and`
|
|
214
|
-
+ ` /${MANIFEST_DIR}/<release>.json on each production origin; until one is published the tag records nothing.`
|
|
215
|
-
+ ` \`${manifestCommand(ctx.appPath)}\` generates it only for an app init sets up itself: once the reason above is gone,`
|
|
216
|
-
+ ' run `haystack init` again and it adds that build step.');
|
|
217
|
-
steps.push(`If production sends a Content-Security-Policy, it needs: ${cspFallbackLine()}.`);
|
|
218
|
-
if (consentStep)
|
|
219
|
-
steps.push(consentStep);
|
|
220
|
-
plan.changes = planner.changes;
|
|
221
|
-
plan.part = part(steps);
|
|
222
|
-
return plan;
|
|
223
|
-
}
|
|
224
232
|
// The tag.
|
|
225
|
-
const integration = await detected.integrate(tags);
|
|
233
|
+
const integration = detected.blocked ? { manual: detected.blocked } : await detected.integrate(tags);
|
|
226
234
|
if (isManual(integration)) {
|
|
227
235
|
steps.push(`Add the tag yourself (${integration.manual}; init looked at ${detected.looked.join(', ')}):\n${detected.manualTag(tags).map(line => ` ${line}`).join('\n')}`);
|
|
228
236
|
}
|
|
@@ -234,8 +242,17 @@ export async function planCapture(input) {
|
|
|
234
242
|
steps.push(`Serve ${posix.join(ctx.appPath, STAGED_DIR)} at ${servedAt}: ${detected.staticDir.manual}.`);
|
|
235
243
|
// The build step and where the manifest goes.
|
|
236
244
|
await planBuild(plan, planner, steps, ctx, detected, config);
|
|
237
|
-
|
|
238
|
-
|
|
245
|
+
if (detected.publishNote)
|
|
246
|
+
plan.notices.push(`Capture: ${detected.publishNote}`);
|
|
247
|
+
// A middleware or proxy that may rewrite page URLs: the routes are exact only with the user's answer.
|
|
248
|
+
if (detected.rewriting && urlRewrites === null)
|
|
249
|
+
steps.push(`Answer one question about URL rewrites: ${detected.rewriting}. ${URL_REWRITES_QUESTION}`);
|
|
250
|
+
// Routes now, so a router init cannot read is a step today rather than a manifest missing after the next build; an
|
|
251
|
+
// adapter whose routes come from the build reads them there.
|
|
252
|
+
if (detected.fromBuild) {
|
|
253
|
+
plan.notices.push(`Capture: the routes come from each production build (${detected.label}), read after it by the build step.`);
|
|
254
|
+
}
|
|
255
|
+
else if (!isManual(detected.build) && detected.build.kind !== 'rake') {
|
|
239
256
|
const routes = exactRoutes(await detected.routes({}));
|
|
240
257
|
if (isManual(routes))
|
|
241
258
|
steps.push(`Make the routes readable or publish the manifest yourself: ${routes.manual}.`);
|
|
@@ -841,7 +841,7 @@ export async function planTelemetry(gitRoot, repository, options) {
|
|
|
841
841
|
const manifest = join(appDir, 'package.json');
|
|
842
842
|
// CONNECT(lane D1): browser capture (init-capture.ts) plans with the answers in effect (flags, else the ones init recorded
|
|
843
843
|
// for this app); a missing answer is a question, never a guess.
|
|
844
|
-
const answers = captureAnswers(appDir, options.origins, options.consent);
|
|
844
|
+
const answers = captureAnswers(appDir, options.origins, options.consent, options.urlRewrites);
|
|
845
845
|
const questions = captureQuestions(answers.origins, answers.consent);
|
|
846
846
|
const capture = questions.length > 0 ? null
|
|
847
847
|
: await planCapture({ gitRoot, appDir, repository, answers, registration: options.registration });
|
|
@@ -940,19 +940,36 @@ function dbProfile(repository, onboarding, notes) {
|
|
|
940
940
|
+ '`haystack db profile upload --help` says how a profile names the one it describes.'] : []),
|
|
941
941
|
] };
|
|
942
942
|
}
|
|
943
|
+
function serverPartOf(plan) {
|
|
944
|
+
return plan.server.status === 'unsupported' ? { status: 'unsupported', why: plan.server.why }
|
|
945
|
+
: plan.server.status === 'manual' ? { status: 'manual', steps: plan.server.steps, received: null } : { status: 'installed', received: null };
|
|
946
|
+
}
|
|
947
|
+
const WAITING_STEP = 'Telemetry waits for answers only your user can give: ask what the `steps` of each `manual` part say, then run `haystack init` '
|
|
948
|
+
+ 'again with them.';
|
|
949
|
+
/** Rule 8(e) before anything is changed (a preview without --yes, or no login yet): the report the apply gives, with
|
|
950
|
+
* `received` null (checking it needs Haystack), so the coding agent can show its user what telemetry will do; with the steps that need
|
|
951
|
+
* nothing from Haystack (the token and environment steps follow with --yes). */
|
|
952
|
+
export function previewTelemetry(plan, repository, notes) {
|
|
953
|
+
const steps = [];
|
|
954
|
+
if (plan.app === null || plan.capture.status === 'manual')
|
|
955
|
+
steps.push(WAITING_STEP);
|
|
956
|
+
if (plan.app !== null && plan.server.status !== 'unsupported') {
|
|
957
|
+
steps.push(`With --yes, init then says how production gets its telemetry token (\`haystack telemetry token --repo ${repository}\`, run by your`
|
|
958
|
+
+ ' user in their own terminal) and which variables production\'s server environment needs.');
|
|
959
|
+
}
|
|
960
|
+
const profile = dbProfile(repository, null, notes);
|
|
961
|
+
return { telemetry: { capture: plan.capture, server: serverPartOf(plan), dbProfileCommand: profile?.command ?? null }, steps };
|
|
962
|
+
}
|
|
943
963
|
/** Rule 8(e): the report, and what the person does for telemetry to arrive (the token, production's environment, the start,
|
|
944
964
|
* the deploy) with the off switches (rule 13c). `onboarding` and `notes` feed the data profile. */
|
|
945
965
|
export async function telemetryReport(plan, context) {
|
|
946
966
|
const { repository, token } = context;
|
|
947
967
|
const steps = [];
|
|
948
|
-
const serverPart = plan
|
|
949
|
-
: plan.server.status === 'manual' ? { status: 'manual', steps: plan.server.steps, received: null } : { status: 'installed', received: null };
|
|
968
|
+
const serverPart = serverPartOf(plan);
|
|
950
969
|
let server = serverPart;
|
|
951
970
|
// Answers only the user can give come first: until then the rest of telemetry waits (rule 8: init never guesses).
|
|
952
|
-
if (plan.app === null || plan.capture.status === 'manual')
|
|
953
|
-
steps.push(
|
|
954
|
-
+ 'again with them.');
|
|
955
|
-
}
|
|
971
|
+
if (plan.app === null || plan.capture.status === 'manual')
|
|
972
|
+
steps.push(WAITING_STEP);
|
|
956
973
|
if (plan.app !== null && plan.server.status !== 'unsupported') {
|
|
957
974
|
const status = await readTelemetryServerStatus(repository, token);
|
|
958
975
|
server = withReceived(serverPart, status.newestFlush === null ? null : received(status.newestFlush.storedAt, status.newestFlush.buildId));
|