helix-sdlc 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Capgemini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,68 @@
1
- # Temporary Holding Version
1
+ ---
2
+ title: helix-sdlc
3
+ description: Public launcher that signs in to the private Helix feed and runs the @helix-sdlc/helix installer.
4
+ ---
2
5
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
6
+ ## Overview
7
+
8
+ `helix-sdlc` installs Helix, Capgemini's AI-native software development lifecycle, with one command. It signs you in to the private Azure Artifacts feed that hosts `@helix-sdlc/helix`, then runs that installer. You do not edit any `.npmrc` file.
9
+
10
+ This package contains no Helix content. The Helix skills, agents, and rules stay on the private feed, and you need read access to that feed to install them. Members of the FS business unit (`DL FSSBU ALL FS`) and the Artifex users group (`GP US SG-Artifex-Users`) can read the feed. They do not ask for access: Azure DevOps admits them automatically the first time they sign in to the `FS-ABS-ATWORK` organization with their Capgemini work account. A group change can take up to 24 hours. Guest and personal accounts are not admitted.
11
+
12
+ Node.js 22 or later is required.
13
+
14
+ This package was named `create-helix-sdlc` before version 0.3.0.
15
+
16
+ ## Install Helix
17
+
18
+ From your project root:
19
+
20
+ ```bash
21
+ npx -y helix-sdlc@latest --harness claude
22
+ ```
23
+
24
+ Use `--harness github` for GitHub Copilot, `--harness artifex` for Artifex, or `--harness all`. The `-y` option stops npx from asking "Ok to proceed?" before it downloads the launcher.
25
+
26
+ ## Update, inspect, or remove Helix
27
+
28
+ Put the command before the options:
29
+
30
+ ```bash
31
+ npx -y helix-sdlc@latest update
32
+ npx -y helix-sdlc@latest status
33
+ npx -y helix-sdlc@latest uninstall --harness claude
34
+ ```
35
+
36
+ With no command, the launcher runs `init`. Every other argument goes to the `helix` installer unchanged, so `--harness`, `--dest`, `--dry-run`, `--force`, `--prune`, `--with-docs`, `--docs-dest`, `--json`, and `--offline` work as the `@helix-sdlc/helix` README describes. The launcher returns the installer's exit code: 0 for success, 1 for an error, and 2 when `init` or `update` left conflicts to merge.
37
+
38
+ ## Sign-in
39
+
40
+ The launcher tries these credentials in order and uses the first one the feed accepts:
41
+
42
+ 1. The `HELIX_NPM_TOKEN` environment variable, holding a personal access token (PAT) or an Entra access token. Use this in CI. A value with the shape of a JWT (three dot-separated parts) is sent as a Bearer token; any other value is sent as a PAT.
43
+ 2. Your Azure CLI sign-in. If you have run `az login --allow-no-subscriptions --tenant 76a2ae5a-9f00-4f6b-95ed-5d33d77c4d61` with your Capgemini account, you are not asked for anything. The launcher always asks Azure CLI for a token from the Capgemini tenant.
44
+ 3. Browser sign-in with your Capgemini account, when it is enabled and you run the launcher in a terminal. The launcher opens your browser, you sign in, and the launcher receives a short-lived token on a `localhost` address. It does not save the token and does not ask for a refresh token. Browser sign-in is off until the Helix app registration is approved; to test a registration early, set `HELIX_CLIENT_ID` to its client ID.
45
+ 4. A PAT saved in your user `.npmrc` for the Helix feed, by an earlier run of the launcher or by the manual setup in the install guide.
46
+ 5. A prompt for a PAT. Create one with the Packaging (Read) scope at <https://dev.azure.com/FS-ABS-ATWORK/_usersSettings/tokens>, then paste it. Input is hidden. The launcher checks the token and offers to save it in your user `.npmrc` with owner-only permissions, so later runs do not ask again.
47
+
48
+ The credential is passed to npm through environment variables for that run only. The launcher writes to your `.npmrc` only when you accept the offer in step 5, and it never writes to the project `.npmrc`.
49
+
50
+ Members of the FS business unit (`DL FSSBU ALL FS`) and the Artifex users group (`GP US SG-Artifex-Users`) get feed access automatically the first time they use it. A group change can take up to 24 hours. The first feed call with your token admits you. You do not need to open Azure DevOps in a browser first.
51
+
52
+ If the feed refuses your Azure CLI or browser sign-in token, the launcher tells you what to check. Admission can take up to 24 hours after you join one of these groups. Check that you signed in with your `@capgemini.com` work account, not a guest or personal account. If it still fails, contact the Helix team.
53
+
54
+ On a machine with no browser, for example over SSH, browser sign-in cannot work, and Capgemini blocks the device code sign-in that would replace it. Use the Azure CLI or a PAT there.
55
+
56
+ If the feed answers 404, the launcher says that the feed was not found. Check `HELIX_REGISTRY` if you set it, or contact the Helix team.
57
+
58
+ ## Launcher options
59
+
60
+ | Flag | Effect |
61
+ |---------------------|--------------------------------------------------------------------------------------------------------|
62
+ | `--helix-version V` | Installs version `V` of `@helix-sdlc/helix` instead of `latest`. |
63
+ | `--pat` | Asks for a PAT even when another credential works, for example to replace an expired saved token. |
64
+ | `--help`, `-h` | Shows the launcher help. After a command (`init --help`), it shows the installer's help instead. |
65
+
66
+ ## Licence
67
+
68
+ MIT. See `LICENSE`.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2026 Capgemini
3
+ // SPDX-License-Identifier: MIT
4
+ import { main } from '../lib/launcher.js';
5
+
6
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,485 @@
1
+ // Copyright (c) 2026 Capgemini
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // Public launcher for the private @helix-sdlc/helix installer. It finds a
5
+ // credential for the org-scoped Helix feed, then runs the installer through
6
+ // `npm exec` with the scope registry and credential passed as npm_config_*
7
+ // environment variables, so no project or user .npmrc edit is needed. This
8
+ // package ships no Helix content.
9
+
10
+ import { execFile as nodeExecFile, spawn as nodeSpawn } from 'node:child_process';
11
+ import { createHash, randomBytes } from 'node:crypto';
12
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { createServer } from 'node:http';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import readline from 'node:readline';
17
+ import { Writable } from 'node:stream';
18
+
19
+ export const PACKAGE = '@helix-sdlc/helix';
20
+ export const ORG_URL = 'https://dev.azure.com/FS-ABS-ATWORK/';
21
+ export const REGISTRY = 'https://pkgs.dev.azure.com/FS-ABS-ATWORK/_packaging/helix@Release/npm/registry/';
22
+ /** Entra resource that Azure DevOps (and the feed) accepts for `az` tokens. */
23
+ export const AZ_RESOURCE = 'https://app.vssps.visualstudio.com';
24
+ /** Capgemini Entra tenant; pins `az` so a guest account in another tenant is not used. */
25
+ export const TENANT = '76a2ae5a-9f00-4f6b-95ed-5d33d77c4d61';
26
+ /** Basic-auth username for PATs. Azure Artifacts accepts any non-empty value. */
27
+ export const AUTH_USER = 'helix';
28
+ export const PAT_URL = `${ORG_URL}_usersSettings/tokens`;
29
+ /**
30
+ * Entra public-client app for browser sign-in, registered by Group IT. Empty means browser sign-in
31
+ * is off; HELIX_CLIENT_ID overrides it for testing a new registration before a release.
32
+ */
33
+ export const CLIENT_ID = '';
34
+ const AUTHORITY = `https://login.microsoftonline.com/${TENANT}/oauth2/v2.0`;
35
+ /** Azure DevOps only. No offline_access, so no refresh token is ever issued. */
36
+ const SCOPE = '499b84ac-1321-427f-aa17-267ca6975798/.default';
37
+ const COMMANDS = new Set(['init', 'update', 'status', 'uninstall']);
38
+ const BLOCK_START = '; begin helix-sdlc feed auth';
39
+ const BLOCK_END = '; end helix-sdlc feed auth';
40
+
41
+ export const USAGE = `Usage: npx -y helix-sdlc@latest [command] [options]
42
+
43
+ Signs in to the private Helix feed and runs ${PACKAGE}. With no command, runs init.
44
+
45
+ Commands (passed to helix): init, update, status, uninstall
46
+ Options (passed to helix): --harness claude|github|artifex|all, --dest PATH,
47
+ --dry-run, --force, --prune, --with-docs,
48
+ --docs-dest PATH, --json, --offline
49
+ Launcher options:
50
+ --helix-version V Install this ${PACKAGE} version (default: latest).
51
+ --pat Ask for a personal access token even when another
52
+ credential is available, and offer to save it.
53
+ --help, -h Show this help. Put it after a command (for example
54
+ "init --help") to see helix's own help.
55
+
56
+ Credentials are tried in this order:
57
+ 1. HELIX_NPM_TOKEN environment variable (a PAT, or an Entra token; for CI)
58
+ 2. Azure CLI sign-in: az login --allow-no-subscriptions --tenant ${TENANT}
59
+ 3. Browser sign-in with your Capgemini account (when enabled)
60
+ 4. A PAT saved in your user .npmrc by an earlier run
61
+ 5. A prompt for a PAT with Packaging (Read) scope: ${PAT_URL}
62
+
63
+ Members of DL FSSBU ALL FS and the Artifex users group get feed access
64
+ automatically on first use. Group changes can take up to 24 hours.
65
+ `;
66
+
67
+ /** Splits launcher flags from the arguments forwarded to helix. */
68
+ export function parseArgs(argv) {
69
+ const opts = { version: 'latest', pat: false, help: false };
70
+ const forward = [];
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const a = argv[i];
73
+ if (a === '--helix-version') {
74
+ if (i + 1 >= argv.length) throw new Error('--helix-version needs a value');
75
+ opts.version = argv[++i];
76
+ } else if (a.startsWith('--helix-version=')) opts.version = a.slice('--helix-version='.length);
77
+ else if (a === '--pat') opts.pat = true;
78
+ else if ((a === '--help' || a === '-h') && !forward.some((f) => COMMANDS.has(f))) opts.help = true;
79
+ else forward.push(a);
80
+ }
81
+ if (!opts.version) throw new Error('--helix-version needs a value');
82
+ if (!forward.some((f) => COMMANDS.has(f))) forward.unshift('init');
83
+ return { opts, forward };
84
+ }
85
+
86
+ /** Registry URL without the scheme, as npm keys per-registry settings. */
87
+ function registryKeys(registry) {
88
+ const noScheme = registry.replace(/^https?:/, '');
89
+ return [noScheme, noScheme.replace(/\/registry\/$/, '/')];
90
+ }
91
+
92
+ /** npm_config_* variables that point the scope at the feed and carry the credential. */
93
+ export function npmEnv(credential, registry = REGISTRY) {
94
+ const env = { 'npm_config_@helix-sdlc:registry': registry };
95
+ if (!credential) return env;
96
+ for (const key of registryKeys(registry)) {
97
+ if (credential.type === 'bearer') env[`npm_config_${key}:_authToken`] = credential.token;
98
+ else {
99
+ env[`npm_config_${key}:username`] = AUTH_USER;
100
+ env[`npm_config_${key}:_password`] = Buffer.from(credential.token).toString('base64');
101
+ env[`npm_config_${key}:email`] = 'npm requires email to be set but does not use the value';
102
+ }
103
+ }
104
+ return env;
105
+ }
106
+
107
+ /** The user .npmrc path npm reads. */
108
+ export function userConfigPath(env, homedir) {
109
+ return env.npm_config_userconfig || env.NPM_CONFIG_USERCONFIG || path.join(homedir, '.npmrc');
110
+ }
111
+
112
+ /** A PAT saved for the feed in a user .npmrc, or null. */
113
+ export function savedPat(text, registry = REGISTRY) {
114
+ const key = `${registryKeys(registry)[0]}:_password`;
115
+ for (const line of text.split(/\r?\n/)) {
116
+ const m = line.match(/^\s*([^=;#]+?)\s*=\s*(.*?)\s*$/);
117
+ if (m && m[1] === key && m[2]) return Buffer.from(m[2].replace(/^"|"$/g, ''), 'base64').toString('utf8');
118
+ }
119
+ return null;
120
+ }
121
+
122
+ /** Returns .npmrc text with the launcher's credential block added or replaced. */
123
+ export function withSavedPat(text, pat, registry = REGISTRY) {
124
+ const encoded = Buffer.from(pat).toString('base64');
125
+ const lines = [BLOCK_START];
126
+ for (const key of registryKeys(registry)) {
127
+ lines.push(`${key}:username=${AUTH_USER}`, `${key}:_password=${encoded}`, `${key}:email=npm requires email to be set but does not use the value`);
128
+ }
129
+ lines.push(BLOCK_END);
130
+ const block = lines.join('\n');
131
+ const start = text.indexOf(BLOCK_START);
132
+ const end = text.indexOf(BLOCK_END);
133
+ if (start !== -1 && end > start) return text.slice(0, start) + block + text.slice(end + BLOCK_END.length);
134
+ const sep = text === '' || text.endsWith('\n') ? '' : '\n';
135
+ return `${text}${sep}${block}\n`;
136
+ }
137
+
138
+ function authHeader(credential) {
139
+ return credential.type === 'bearer'
140
+ ? `Bearer ${credential.token}`
141
+ : `Basic ${Buffer.from(`${AUTH_USER}:${credential.token}`).toString('base64')}`;
142
+ }
143
+
144
+ /** True for a JWT-shaped value (three base64url segments), which Azure DevOps takes as Bearer. */
145
+ export function isJwt(token) {
146
+ return /^[\w-]+\.[\w-]+\.[\w-]+$/.test(token);
147
+ }
148
+
149
+ /** Checks a credential against the feed. Returns 'ok', 'unauthorized', 'forbidden', 'not-found', or 'error: ...'. */
150
+ export async function probe(credential, { fetch, registry = REGISTRY }) {
151
+ try {
152
+ const res = await fetch(`${registry}${PACKAGE.replace('/', '%2f')}`, {
153
+ headers: { Authorization: authHeader(credential), Accept: 'application/json' },
154
+ signal: AbortSignal.timeout(15000),
155
+ });
156
+ if (res.ok) return 'ok';
157
+ if (res.status === 401) return 'unauthorized';
158
+ if (res.status === 403) return 'forbidden';
159
+ if (res.status === 404) return 'not-found';
160
+ return `error: HTTP ${res.status}`;
161
+ } catch (e) {
162
+ return `error: ${e.message}`;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Windows `.cmd` shims (az.cmd, npm.cmd) need cmd.exe. Passing an argument list with
168
+ * `shell: true` is deprecated (DEP0190), so run cmd.exe directly with one quoted command line.
169
+ * cmd.exe expands `%NAME%` even inside quotes and has no reliable escape, so `%` is rejected.
170
+ */
171
+ export function winCommand(cmd, args) {
172
+ const bad = [cmd, ...args].find((a) => a.includes('%'));
173
+ if (bad !== undefined) {
174
+ throw new Error(`cannot pass "${bad}" through cmd.exe because it contains %. Use a path without %.`);
175
+ }
176
+ const quote = (a) => (/^[\w@+=:,./-]+$/.test(a) ? a : `"${a.replace(/"/g, '""')}"`);
177
+ return ['cmd.exe', ['/d', '/s', '/c', [cmd, ...args].map(quote).join(' ')]];
178
+ }
179
+
180
+ /** Best-effort `az account get-access-token`; resolves null when az is missing or signed out. */
181
+ export function azToken({ execFile = nodeExecFile, platform = process.platform } = {}) {
182
+ return new Promise((resolve) => {
183
+ const args = ['account', 'get-access-token', '--resource', AZ_RESOURCE, '--tenant', TENANT, '--query', 'accessToken', '-o', 'tsv'];
184
+ const [file, argv] = platform === 'win32' ? winCommand('az', args) : ['az', args];
185
+ try {
186
+ execFile(
187
+ file,
188
+ argv,
189
+ { timeout: 30000, windowsHide: true },
190
+ (err, out) => resolve(err ? null : String(out).trim() || null),
191
+ );
192
+ } catch {
193
+ resolve(null);
194
+ }
195
+ });
196
+ }
197
+
198
+ /** Reads a line from the terminal without echoing it. */
199
+ export function promptHidden(question, { input = process.stdin, output = process.stdout } = {}) {
200
+ return new Promise((resolve) => {
201
+ let muted = false;
202
+ const sink = new Writable({
203
+ write(chunk, _enc, cb) {
204
+ if (!muted) output.write(chunk);
205
+ cb();
206
+ },
207
+ });
208
+ const rl = readline.createInterface({ input, output: sink, terminal: true });
209
+ output.write(question);
210
+ muted = true;
211
+ rl.question('', (answer) => {
212
+ muted = false;
213
+ output.write('\n');
214
+ rl.close();
215
+ resolve(answer.trim());
216
+ });
217
+ });
218
+ }
219
+
220
+ /** Reads a visible line from the terminal. */
221
+ export function promptLine(question, { input = process.stdin, output = process.stdout } = {}) {
222
+ return new Promise((resolve) => {
223
+ const rl = readline.createInterface({ input, output });
224
+ rl.question(question, (answer) => {
225
+ rl.close();
226
+ resolve(answer.trim());
227
+ });
228
+ });
229
+ }
230
+
231
+ /** Opens a URL in the default browser, best effort. WSL uses the Windows browser. */
232
+ export function openUrl(url, { spawn = nodeSpawn, platform = process.platform, env = process.env } = {}) {
233
+ let cmd;
234
+ let args;
235
+ if (platform === 'win32') [cmd, args] = ['rundll32', ['url.dll,FileProtocolHandler', url]];
236
+ else if (platform === 'darwin') [cmd, args] = ['open', [url]];
237
+ else if (env.WSL_DISTRO_NAME) [cmd, args] = ['powershell.exe', ['-NoProfile', '-Command', `Start-Process '${url}'`]];
238
+ else [cmd, args] = ['xdg-open', [url]];
239
+ try {
240
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true });
241
+ child.on('error', () => {});
242
+ child.unref();
243
+ } catch {
244
+ // The URL is always printed, so a failed launch is not fatal.
245
+ }
246
+ }
247
+
248
+ /** Starts a one-shot loopback listener on 127.0.0.1 and, best effort, ::1 on the same port. */
249
+ async function listenLoopback() {
250
+ const v4 = createServer();
251
+ await new Promise((ok, fail) => v4.once('error', fail).listen(0, '127.0.0.1', ok));
252
+ const servers = [v4];
253
+ const v6 = createServer();
254
+ await new Promise((ok) => v6.once('error', ok).listen(v4.address().port, '::1', () => (servers.push(v6), ok())));
255
+ return { port: v4.address().port, servers };
256
+ }
257
+
258
+ /**
259
+ * Signs in through the system browser with the authorization code flow and PKCE, and returns an
260
+ * Azure DevOps access token. Nothing is written to disk and no refresh token is requested.
261
+ */
262
+ export async function browserToken({ clientId, openUrl, log, fetch = globalThis.fetch, timeoutMs = 300_000 }) {
263
+ const verifier = randomBytes(32).toString('base64url');
264
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
265
+ const state = randomBytes(16).toString('base64url');
266
+ const { port, servers } = await listenLoopback();
267
+ const redirectUri = `http://localhost:${port}/`;
268
+ try {
269
+ const code = await new Promise((resolve, reject) => {
270
+ const timer = setTimeout(() => reject(new Error('browser sign-in timed out')), timeoutMs);
271
+ const onRequest = (req, res) => {
272
+ const got = new URL(req.url, redirectUri).searchParams;
273
+ // Ignore favicon requests and anything else that is not the sign-in response.
274
+ if (!got.has('code') && !got.has('error')) {
275
+ res.writeHead(404).end();
276
+ return;
277
+ }
278
+ clearTimeout(timer);
279
+ const ok = got.get('state') === state && got.has('code');
280
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
281
+ res.end(ok ? 'Helix sign-in is complete. You can close this tab.' : 'Helix sign-in failed. Return to the terminal for details.');
282
+ if (got.get('state') !== state) reject(new Error('browser sign-in response failed the state check'));
283
+ else if (got.has('error')) reject(new Error(`browser sign-in failed: ${got.get('error')} ${got.get('error_description') ?? ''}`.trim()));
284
+ else resolve(got.get('code'));
285
+ };
286
+ for (const s of servers) s.on('request', onRequest);
287
+ const url = `${AUTHORITY}/authorize?${new URLSearchParams({
288
+ client_id: clientId,
289
+ response_type: 'code',
290
+ response_mode: 'query',
291
+ redirect_uri: redirectUri,
292
+ scope: SCOPE,
293
+ state,
294
+ code_challenge: challenge,
295
+ code_challenge_method: 'S256',
296
+ prompt: 'select_account',
297
+ domain_hint: 'capgemini.com',
298
+ })}`;
299
+ log(`Opening your browser to sign in with your Capgemini account. If it does not open, visit:\n ${url}`);
300
+ openUrl(url);
301
+ });
302
+ const res = await fetch(`${AUTHORITY}/token`, {
303
+ method: 'POST',
304
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
305
+ body: new URLSearchParams({
306
+ client_id: clientId,
307
+ grant_type: 'authorization_code',
308
+ code,
309
+ redirect_uri: redirectUri,
310
+ code_verifier: verifier,
311
+ scope: SCOPE,
312
+ }).toString(),
313
+ signal: AbortSignal.timeout(30_000),
314
+ });
315
+ const body = await res.json().catch(() => ({}));
316
+ if (!res.ok || body.token_type !== 'Bearer' || !body.access_token) {
317
+ throw new Error(`browser sign-in token request failed: ${body.error ?? `HTTP ${res.status}`}`);
318
+ }
319
+ return body.access_token;
320
+ } finally {
321
+ for (const s of servers) s.close();
322
+ }
323
+ }
324
+
325
+ const ADMISSION_HINT =
326
+ 'You are signed in but cannot read the Helix feed yet. Members of the FS business unit (DL FSSBU ALL FS) ' +
327
+ 'and the Artifex users group get access automatically the first time they use it. Group changes can take ' +
328
+ 'up to 24 hours. Check that you signed in with your @capgemini.com work account, not a guest or personal ' +
329
+ 'account. If it still fails, contact the Helix team.';
330
+ const NOT_FOUND_HINT = (registry) =>
331
+ `The Helix feed was not found at ${registry}. If you set HELIX_REGISTRY, check its value. ` +
332
+ 'Otherwise contact the Helix team.';
333
+
334
+ /** Finds a credential the feed accepts. Returns { credential, source } or throws with guidance. */
335
+ export async function resolveCredential(opts, deps) {
336
+ const { env, log, fetch, isTTY } = deps;
337
+ const notes = [];
338
+ let refused = false;
339
+ const tryCred = async (credential, source, { entra = false } = {}) => {
340
+ const r = await probe(credential, { fetch, registry: deps.registry });
341
+ if (r === 'ok') return { credential, source };
342
+ if (r === 'not-found') throw new Error(NOT_FOUND_HINT(deps.registry ?? REGISTRY));
343
+ if (r === 'forbidden') throw new Error(ADMISSION_HINT);
344
+ if (entra && r === 'unauthorized') refused = true;
345
+ notes.push(`${source}: ${r === 'unauthorized' ? 'rejected by the feed' : r}`);
346
+ return null;
347
+ };
348
+
349
+ if (env.HELIX_NPM_TOKEN) {
350
+ const token = env.HELIX_NPM_TOKEN;
351
+ const hit = await tryCred({ type: isJwt(token) ? 'bearer' : 'basic', token }, 'HELIX_NPM_TOKEN');
352
+ if (hit) return hit;
353
+ throw new Error('HELIX_NPM_TOKEN was rejected by the feed. Check that the token is valid and, for a PAT, has the Packaging (Read) scope.');
354
+ }
355
+
356
+ const rcPath = userConfigPath(env, deps.homedir);
357
+ const rcText = existsSync(rcPath) ? readFileSync(rcPath, 'utf8') : '';
358
+
359
+ if (!opts.pat) {
360
+ const token = await deps.azToken();
361
+ if (token) {
362
+ const hit = await tryCred({ type: 'bearer', token }, 'Azure CLI', { entra: true });
363
+ if (hit) return hit;
364
+ } else notes.push('Azure CLI: not installed or not signed in');
365
+
366
+ const clientId = env.HELIX_CLIENT_ID || deps.clientId;
367
+ if (clientId && isTTY) {
368
+ let token = null;
369
+ try {
370
+ token = await deps.browserToken({ clientId, log, fetch, openUrl: deps.openUrl });
371
+ } catch (e) {
372
+ notes.push(`Browser sign-in: ${e.message}`);
373
+ }
374
+ if (token) {
375
+ const hit = await tryCred({ type: 'bearer', token }, 'browser sign-in', { entra: true });
376
+ if (hit) return hit;
377
+ }
378
+ }
379
+
380
+ const pat = savedPat(rcText, deps.registry);
381
+ if (pat) {
382
+ const hit = await tryCred({ type: 'basic', token: pat }, `PAT saved in ${rcPath}`);
383
+ if (hit) return hit;
384
+ }
385
+ }
386
+
387
+ if (!isTTY) {
388
+ throw new Error(
389
+ `No working credential for the Helix feed (${notes.join('; ') || 'none found'}).\n` +
390
+ (refused ? `${ADMISSION_HINT}\n` : '') +
391
+ `Run "az login --allow-no-subscriptions --tenant ${TENANT}", or set HELIX_NPM_TOKEN to a PAT with the Packaging (Read) scope.`,
392
+ );
393
+ }
394
+
395
+ for (const n of notes) log(` ${n}`);
396
+ if (refused) log(ADMISSION_HINT);
397
+ log(`Create a personal access token with the Packaging (Read) scope at:\n ${PAT_URL}`);
398
+ const pat = await deps.promptHidden('Paste the token (input is hidden): ');
399
+ if (!pat) throw new Error('No token entered.');
400
+ const hit = await tryCred({ type: 'basic', token: pat }, 'Entered PAT');
401
+ if (!hit) throw new Error('The feed rejected that token. Check that it is valid and has the Packaging (Read) scope.');
402
+ const answer = await deps.promptLine(`Save the token in ${rcPath} so you are not asked again? [Y/n] `);
403
+ if (!/^n/i.test(answer)) {
404
+ writeFileSync(rcPath, withSavedPat(rcText, pat, deps.registry), { mode: 0o600 });
405
+ log(`Saved. Remove the "${BLOCK_START}" block from ${rcPath} to forget it.`);
406
+ }
407
+ return hit;
408
+ }
409
+
410
+ /** Locates npm-cli.js so npm can be run through the current node on every platform. */
411
+ export function findNpmCli({ env, execPath = process.execPath }) {
412
+ const candidates = [];
413
+ if (env.npm_execpath) {
414
+ candidates.push(env.npm_execpath.replace(/npx-cli\.js$/, 'npm-cli.js'));
415
+ }
416
+ const nodeDir = path.dirname(execPath);
417
+ candidates.push(
418
+ path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
419
+ path.join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
420
+ );
421
+ return candidates.find((c) => c.endsWith('npm-cli.js') && existsSync(c)) ?? null;
422
+ }
423
+
424
+ /** Runs helix through npm exec and resolves with its exit code. */
425
+ export function runHelix(forward, opts, extraEnv, { env, spawn = nodeSpawn, platform = process.platform, execPath = process.execPath }) {
426
+ const npmArgs = ['exec', '--yes', `--package=${PACKAGE}@${opts.version}`, '--', 'helix', ...forward];
427
+ const npmCli = findNpmCli({ env, execPath });
428
+ const childEnv = { ...env, ...extraEnv };
429
+ const child = npmCli
430
+ ? spawn(execPath, [npmCli, ...npmArgs], { stdio: 'inherit', env: childEnv })
431
+ : spawn(...(platform === 'win32' ? winCommand('npm', npmArgs) : ['npm', npmArgs]), { stdio: 'inherit', env: childEnv });
432
+ return new Promise((resolve) => {
433
+ child.on('error', () => resolve(1));
434
+ child.on('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
435
+ });
436
+ }
437
+
438
+ /** CLI entry point. Returns the process exit code. */
439
+ export async function main(argv, io = {}) {
440
+ const env = io.env ?? process.env;
441
+ const stdout = io.stdout ?? process.stdout;
442
+ const stderr = io.stderr ?? process.stderr;
443
+ const log = (s) => stderr.write(`${s}\n`);
444
+ let parsed;
445
+ try {
446
+ parsed = parseArgs(argv);
447
+ } catch (e) {
448
+ log(`helix-sdlc: ${e.message}`);
449
+ return 1;
450
+ }
451
+ if (parsed.opts.help) {
452
+ stdout.write(USAGE);
453
+ return 0;
454
+ }
455
+ const deps = {
456
+ env,
457
+ log,
458
+ registry: env.HELIX_REGISTRY || REGISTRY,
459
+ fetch: io.fetch ?? globalThis.fetch,
460
+ isTTY: io.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY),
461
+ homedir: io.homedir ?? os.homedir(),
462
+ azToken: io.azToken ?? (() => azToken()),
463
+ openUrl: io.openUrl ?? ((u) => openUrl(u)),
464
+ clientId: io.clientId ?? CLIENT_ID,
465
+ browserToken: io.browserToken ?? browserToken,
466
+ promptHidden: io.promptHidden ?? ((q) => promptHidden(q)),
467
+ promptLine: io.promptLine ?? ((q) => promptLine(q)),
468
+ };
469
+ let resolved;
470
+ try {
471
+ resolved = await resolveCredential(parsed.opts, deps);
472
+ } catch (e) {
473
+ log(`helix-sdlc: ${e.message}`);
474
+ return 1;
475
+ }
476
+ log(`helix-sdlc: using ${resolved.source} for the Helix feed`);
477
+ // HELIX_LAUNCHER makes helix's hints name this launcher instead of the helix bin.
478
+ const helixEnv = { ...npmEnv(resolved.credential, deps.registry), HELIX_LAUNCHER: 'helix-sdlc' };
479
+ try {
480
+ return await (io.runHelix ?? runHelix)(parsed.forward, parsed.opts, helixEnv, { env });
481
+ } catch (e) {
482
+ log(`helix-sdlc: ${e.message}`);
483
+ return 1;
484
+ }
485
+ }
package/package.json CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "name": "helix-sdlc",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Signs in to the private Helix feed and runs the @helix-sdlc/helix installer. Contains no Helix content.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": { "helix-sdlc": "bin/helix-sdlc.js" },
8
+ "files": ["bin/", "lib/", "LICENSE", "README.md"],
9
+ "engines": { "node": ">=22" },
10
+ "scripts": { "test": "node --test \"test/**/*.test.js\"" },
11
+ "publishConfig": { "registry": "https://registry.npmjs.org/", "access": "public" }
12
+ }